Seth commited on
Commit ·
cdd2ae9
1
Parent(s): 5fa5fa1
update
Browse files
backend/app/main.py
CHANGED
|
@@ -18,6 +18,7 @@ import asyncio
|
|
| 18 |
import math
|
| 19 |
import re
|
| 20 |
import requests
|
|
|
|
| 21 |
from datetime import datetime, timedelta
|
| 22 |
from calendar import monthrange
|
| 23 |
|
|
@@ -159,12 +160,53 @@ def _pick_linkedin_url(raw_data: Dict) -> str:
|
|
| 159 |
"linkedin url",
|
| 160 |
"linkedin profile",
|
| 161 |
"person linkedin url",
|
|
|
|
| 162 |
"linkedin profile url",
|
|
|
|
| 163 |
"linkedin_url",
|
|
|
|
| 164 |
],
|
| 165 |
)
|
| 166 |
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
def _require_unipile_config():
|
| 169 |
if not UNIPILE_API_BASE or not UNIPILE_API_KEY:
|
| 170 |
raise HTTPException(
|
|
@@ -181,7 +223,11 @@ def _unipile_headers():
|
|
| 181 |
}
|
| 182 |
|
| 183 |
|
| 184 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
_require_unipile_config()
|
| 186 |
url = f"{UNIPILE_API_BASE}{path}"
|
| 187 |
try:
|
|
@@ -199,7 +245,12 @@ def _unipile_request(method: str, path: str, payload: Optional[dict] = None):
|
|
| 199 |
data = resp.json()
|
| 200 |
except Exception:
|
| 201 |
data = {"raw": resp.text}
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
msg = None
|
| 204 |
if isinstance(data, dict):
|
| 205 |
msg = (
|
|
@@ -211,12 +262,20 @@ def _unipile_request(method: str, path: str, payload: Optional[dict] = None):
|
|
| 211 |
if isinstance(msg, (dict, list)):
|
| 212 |
msg = json.dumps(msg)
|
| 213 |
raise HTTPException(
|
| 214 |
-
status_code=
|
| 215 |
-
detail=msg or f"UniPile error ({
|
| 216 |
)
|
| 217 |
return data
|
| 218 |
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
def _public_origin_from_request(request: Request) -> str:
|
| 221 |
"""
|
| 222 |
Resolve browser-facing origin behind proxies (HF Spaces, ingress).
|
|
@@ -231,6 +290,45 @@ def _public_origin_from_request(request: Request) -> str:
|
|
| 231 |
return str(request.base_url).rstrip("/")
|
| 232 |
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
def _contact_value(contact: Contact, field: str):
|
| 235 |
if field == "first_name":
|
| 236 |
return contact.first_name or _pick_from_raw(contact.raw_data, ["first name", "firstname", "first_name"])
|
|
@@ -1023,6 +1121,8 @@ async def list_unipile_linkedin_accounts(t: TenantContext = Depends(get_tenant_c
|
|
| 1023 |
{
|
| 1024 |
"id": r.id,
|
| 1025 |
"label": r.label or "LinkedIn account",
|
|
|
|
|
|
|
| 1026 |
"provider": r.provider,
|
| 1027 |
"unipile_account_id": r.unipile_account_id,
|
| 1028 |
"status": r.status or "unknown",
|
|
@@ -1092,6 +1192,12 @@ async def unipile_linkedin_hosted_callback(request: Request, db: Session = Depen
|
|
| 1092 |
raise HTTPException(status_code=400, detail="Missing name token")
|
| 1093 |
if not unipile_account_id:
|
| 1094 |
return {"ok": True, "ignored": True, "reason": "missing_account_id"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1095 |
|
| 1096 |
state = (
|
| 1097 |
db.query(UnipileHostedAuthState)
|
|
@@ -1122,7 +1228,7 @@ async def unipile_linkedin_hosted_callback(request: Request, db: Session = Depen
|
|
| 1122 |
existing.label = state.label or existing.label
|
| 1123 |
existing.status = status or existing.status
|
| 1124 |
existing.auth_mode = "hosted"
|
| 1125 |
-
existing.metadata_json = body
|
| 1126 |
existing.updated_at = datetime.utcnow()
|
| 1127 |
else:
|
| 1128 |
db.add(
|
|
@@ -1134,7 +1240,7 @@ async def unipile_linkedin_hosted_callback(request: Request, db: Session = Depen
|
|
| 1134 |
unipile_account_id=unipile_account_id,
|
| 1135 |
status=status or "connected",
|
| 1136 |
auth_mode="hosted",
|
| 1137 |
-
metadata_json=body,
|
| 1138 |
)
|
| 1139 |
)
|
| 1140 |
state.used_at = datetime.utcnow()
|
|
@@ -1173,6 +1279,10 @@ async def connect_unipile_linkedin(
|
|
| 1173 |
account_id = _safe_str((response or {}).get("account", {}).get("id") if isinstance(response, dict) else "")
|
| 1174 |
if not account_id:
|
| 1175 |
raise HTTPException(status_code=400, detail="UniPile did not return an account_id")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1176 |
|
| 1177 |
db_row = UnipileAccount(
|
| 1178 |
tenant_id=t.tenant_id,
|
|
@@ -1182,7 +1292,7 @@ async def connect_unipile_linkedin(
|
|
| 1182 |
unipile_account_id=account_id,
|
| 1183 |
status=_safe_str(response.get("status") if isinstance(response, dict) else "") or "connected",
|
| 1184 |
auth_mode=body.auth_mode,
|
| 1185 |
-
metadata_json=
|
| 1186 |
)
|
| 1187 |
t.db.add(db_row)
|
| 1188 |
t.db.commit()
|
|
@@ -1227,6 +1337,7 @@ async def list_linkedin_campaigns(t: TenantContext = Depends(get_tenant_context)
|
|
| 1227 |
"unipile_account_label": a.label if a else "",
|
| 1228 |
"created_at": c.created_at.isoformat() if c.created_at else None,
|
| 1229 |
"executed_at": c.executed_at.isoformat() if c.executed_at else None,
|
|
|
|
| 1230 |
}
|
| 1231 |
for c, a in rows
|
| 1232 |
]
|
|
@@ -1315,9 +1426,13 @@ async def upload_linkedin_campaign_csv(
|
|
| 1315 |
GeneratedSequence.product == campaign.name,
|
| 1316 |
).delete()
|
| 1317 |
|
|
|
|
| 1318 |
for idx, row in df.iterrows():
|
| 1319 |
row_dict = row.to_dict()
|
| 1320 |
sanitized_raw_data = {str(k): _json_safe(v) for k, v in row_dict.items()}
|
|
|
|
|
|
|
|
|
|
| 1321 |
contact = Contact(
|
| 1322 |
tenant_id=t.tenant_id,
|
| 1323 |
file_id=file_id,
|
|
@@ -1337,7 +1452,12 @@ async def upload_linkedin_campaign_csv(
|
|
| 1337 |
campaign.status = "draft"
|
| 1338 |
campaign.updated_at = datetime.utcnow()
|
| 1339 |
db.commit()
|
| 1340 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1341 |
|
| 1342 |
|
| 1343 |
@app.post("/api/linkedin-campaigns/{campaign_id}/generate")
|
|
@@ -1462,8 +1582,14 @@ async def execute_linkedin_campaign(
|
|
| 1462 |
t: TenantContext = Depends(get_tenant_context),
|
| 1463 |
):
|
| 1464 |
"""
|
| 1465 |
-
Execute
|
| 1466 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1467 |
"""
|
| 1468 |
db = t.db
|
| 1469 |
campaign = (
|
|
@@ -1512,87 +1638,236 @@ async def execute_linkedin_campaign(
|
|
| 1512 |
.order_by(GeneratedSequence.sequence_id.asc())
|
| 1513 |
.all()
|
| 1514 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1515 |
msg_by_seq = {r.sequence_id: r for r in seq_rows}
|
| 1516 |
|
| 1517 |
-
|
|
|
|
|
|
|
| 1518 |
skipped = 0
|
| 1519 |
failed = 0
|
| 1520 |
errors = []
|
| 1521 |
attempts = []
|
| 1522 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1523 |
for idx, c in enumerate(contacts, start=1):
|
| 1524 |
first_msg = msg_by_seq.get(idx)
|
|
|
|
| 1525 |
if not first_msg:
|
| 1526 |
skipped += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1527 |
continue
|
| 1528 |
-
|
| 1529 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1530 |
skipped += 1
|
| 1531 |
errors.append(
|
| 1532 |
{
|
| 1533 |
"contact_id": c.id,
|
| 1534 |
"reason": "missing_linkedin_url",
|
| 1535 |
-
"message": "No LinkedIn profile URL
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1536 |
}
|
| 1537 |
)
|
| 1538 |
continue
|
|
|
|
|
|
|
|
|
|
| 1539 |
if body.dry_run:
|
| 1540 |
-
|
| 1541 |
-
attempts.append(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1542 |
continue
|
| 1543 |
-
|
| 1544 |
-
|
| 1545 |
-
|
| 1546 |
-
|
| 1547 |
-
|
| 1548 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1549 |
)
|
| 1550 |
-
|
| 1551 |
-
|
| 1552 |
-
|
| 1553 |
-
|
| 1554 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1555 |
|
| 1556 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1557 |
chat = _unipile_request(
|
| 1558 |
"POST",
|
| 1559 |
"/api/v1/chats",
|
| 1560 |
-
{
|
| 1561 |
-
"account_id": account.unipile_account_id,
|
| 1562 |
-
"attendees": [attendee_id],
|
| 1563 |
-
},
|
| 1564 |
)
|
| 1565 |
chat_id = _safe_str(chat.get("id") if isinstance(chat, dict) else "")
|
| 1566 |
if not chat_id:
|
| 1567 |
raise ValueError("UniPile chat id not found")
|
| 1568 |
-
|
| 1569 |
-
# 3) send first generated LinkedIn message
|
| 1570 |
_unipile_request(
|
| 1571 |
"POST",
|
| 1572 |
-
f"/api/v1/chats/{chat_id}/messages",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1573 |
{
|
| 1574 |
-
"
|
| 1575 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1576 |
)
|
| 1577 |
-
sent += 1
|
| 1578 |
-
attempts.append({"contact_id": c.id, "linkedin_url": linkedin_url, "status": "sent"})
|
| 1579 |
except Exception as e:
|
| 1580 |
failed += 1
|
| 1581 |
errors.append(
|
| 1582 |
{
|
| 1583 |
"contact_id": c.id,
|
| 1584 |
-
"reason": "
|
| 1585 |
-
"message": str(e),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1586 |
}
|
| 1587 |
)
|
| 1588 |
|
|
|
|
| 1589 |
result = {
|
| 1590 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1591 |
"skipped": skipped,
|
| 1592 |
"failed": failed,
|
| 1593 |
-
"errors": errors[:
|
| 1594 |
-
"attempts": attempts[:
|
| 1595 |
-
"
|
|
|
|
| 1596 |
}
|
| 1597 |
campaign.status = "completed" if failed == 0 else "failed"
|
| 1598 |
campaign.execution_result = result
|
|
|
|
| 18 |
import math
|
| 19 |
import re
|
| 20 |
import requests
|
| 21 |
+
from urllib.parse import urlparse, unquote
|
| 22 |
from datetime import datetime, timedelta
|
| 23 |
from calendar import monthrange
|
| 24 |
|
|
|
|
| 160 |
"linkedin url",
|
| 161 |
"linkedin profile",
|
| 162 |
"person linkedin url",
|
| 163 |
+
"person linkedin profile url",
|
| 164 |
"linkedin profile url",
|
| 165 |
+
"linkedin public url",
|
| 166 |
"linkedin_url",
|
| 167 |
+
"linkedin profile link",
|
| 168 |
],
|
| 169 |
)
|
| 170 |
|
| 171 |
|
| 172 |
+
def _normalize_linkedin_url(raw: str) -> str:
|
| 173 |
+
"""Strip whitespace; upgrade http→https for stable display and parsing."""
|
| 174 |
+
s = _safe_str(raw)
|
| 175 |
+
if not s:
|
| 176 |
+
return ""
|
| 177 |
+
if s.startswith("http://"):
|
| 178 |
+
s = "https://" + s[len("http://") :]
|
| 179 |
+
return s
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _linkedin_public_identifier(raw: str) -> str:
|
| 183 |
+
"""
|
| 184 |
+
UniPile profile lookup expects the public slug from /in/{slug}, not a full URL.
|
| 185 |
+
Accepts plain slugs (e.g. satyanadella) as well.
|
| 186 |
+
"""
|
| 187 |
+
s = _normalize_linkedin_url(raw).strip()
|
| 188 |
+
if not s:
|
| 189 |
+
return ""
|
| 190 |
+
low = s.lower()
|
| 191 |
+
if "linkedin.com" in low:
|
| 192 |
+
try:
|
| 193 |
+
path = urlparse(s).path or ""
|
| 194 |
+
except Exception:
|
| 195 |
+
path = ""
|
| 196 |
+
path = unquote(path).strip("/")
|
| 197 |
+
parts = [p for p in path.split("/") if p]
|
| 198 |
+
for i, seg in enumerate(parts):
|
| 199 |
+
if seg.lower() == "in" and i + 1 < len(parts):
|
| 200 |
+
slug = parts[i + 1].strip()
|
| 201 |
+
return slug.split("?")[0].strip()
|
| 202 |
+
if seg.lower() == "sales" and i + 2 < len(parts) and parts[i + 1].lower() == "lead":
|
| 203 |
+
# /sales/lead/{slug}
|
| 204 |
+
return parts[i + 2].split("?")[0].strip()
|
| 205 |
+
return ""
|
| 206 |
+
# Already a slug / identifier (no URL)
|
| 207 |
+
return s.split("?")[0].strip("/").strip()
|
| 208 |
+
|
| 209 |
+
|
| 210 |
def _require_unipile_config():
|
| 211 |
if not UNIPILE_API_BASE or not UNIPILE_API_KEY:
|
| 212 |
raise HTTPException(
|
|
|
|
| 223 |
}
|
| 224 |
|
| 225 |
|
| 226 |
+
def _unipile_call(method: str, path: str, payload: Optional[dict] = None):
|
| 227 |
+
"""
|
| 228 |
+
Raw UniPile HTTP call. Returns (status_code, parsed_body).
|
| 229 |
+
Raises HTTPException only on transport failure (not on 4xx/5xx responses).
|
| 230 |
+
"""
|
| 231 |
_require_unipile_config()
|
| 232 |
url = f"{UNIPILE_API_BASE}{path}"
|
| 233 |
try:
|
|
|
|
| 245 |
data = resp.json()
|
| 246 |
except Exception:
|
| 247 |
data = {"raw": resp.text}
|
| 248 |
+
return resp.status_code, data
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _unipile_request(method: str, path: str, payload: Optional[dict] = None):
|
| 252 |
+
status, data = _unipile_call(method, path, payload)
|
| 253 |
+
if status >= 400:
|
| 254 |
msg = None
|
| 255 |
if isinstance(data, dict):
|
| 256 |
msg = (
|
|
|
|
| 262 |
if isinstance(msg, (dict, list)):
|
| 263 |
msg = json.dumps(msg)
|
| 264 |
raise HTTPException(
|
| 265 |
+
status_code=status,
|
| 266 |
+
detail=msg or f"UniPile error ({status}): {data if not isinstance(data, dict) else json.dumps(data)}",
|
| 267 |
)
|
| 268 |
return data
|
| 269 |
|
| 270 |
|
| 271 |
+
def _linkedin_invite_note(text: str) -> str:
|
| 272 |
+
"""LinkedIn personalized invite notes are short; keep a safe upper bound."""
|
| 273 |
+
t = _safe_str(text).replace("\r\n", "\n").strip()
|
| 274 |
+
if len(t) <= 280:
|
| 275 |
+
return t
|
| 276 |
+
return t[:277].rstrip() + "..."
|
| 277 |
+
|
| 278 |
+
|
| 279 |
def _public_origin_from_request(request: Request) -> str:
|
| 280 |
"""
|
| 281 |
Resolve browser-facing origin behind proxies (HF Spaces, ingress).
|
|
|
|
| 290 |
return str(request.base_url).rstrip("/")
|
| 291 |
|
| 292 |
|
| 293 |
+
def _extract_unipile_identity(data: Optional[dict]) -> tuple[str, str]:
|
| 294 |
+
"""
|
| 295 |
+
Best-effort extraction of profile display name and avatar URL from UniPile account payloads.
|
| 296 |
+
"""
|
| 297 |
+
if not isinstance(data, dict):
|
| 298 |
+
return "", ""
|
| 299 |
+
candidates_name = [
|
| 300 |
+
data.get("name"),
|
| 301 |
+
data.get("display_name"),
|
| 302 |
+
data.get("full_name"),
|
| 303 |
+
(data.get("user") or {}).get("name") if isinstance(data.get("user"), dict) else None,
|
| 304 |
+
(data.get("profile") or {}).get("name") if isinstance(data.get("profile"), dict) else None,
|
| 305 |
+
(data.get("account") or {}).get("name") if isinstance(data.get("account"), dict) else None,
|
| 306 |
+
]
|
| 307 |
+
candidates_avatar = [
|
| 308 |
+
data.get("picture"),
|
| 309 |
+
data.get("avatar"),
|
| 310 |
+
data.get("photo_url"),
|
| 311 |
+
data.get("image_url"),
|
| 312 |
+
(data.get("user") or {}).get("picture") if isinstance(data.get("user"), dict) else None,
|
| 313 |
+
(data.get("profile") or {}).get("picture") if isinstance(data.get("profile"), dict) else None,
|
| 314 |
+
(data.get("profile") or {}).get("avatar") if isinstance(data.get("profile"), dict) else None,
|
| 315 |
+
]
|
| 316 |
+
name = next((_safe_str(x) for x in candidates_name if _safe_str(x)), "")
|
| 317 |
+
avatar = next((_safe_str(x) for x in candidates_avatar if _safe_str(x)), "")
|
| 318 |
+
return name, avatar
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def _try_fetch_unipile_account_profile(account_id: str) -> dict:
|
| 322 |
+
"""
|
| 323 |
+
Optional enrichment call. If it fails, we just keep existing metadata.
|
| 324 |
+
"""
|
| 325 |
+
try:
|
| 326 |
+
out = _unipile_request("GET", f"/api/v1/accounts/{requests.utils.quote(account_id, safe='')}")
|
| 327 |
+
return out if isinstance(out, dict) else {}
|
| 328 |
+
except Exception:
|
| 329 |
+
return {}
|
| 330 |
+
|
| 331 |
+
|
| 332 |
def _contact_value(contact: Contact, field: str):
|
| 333 |
if field == "first_name":
|
| 334 |
return contact.first_name or _pick_from_raw(contact.raw_data, ["first name", "firstname", "first_name"])
|
|
|
|
| 1121 |
{
|
| 1122 |
"id": r.id,
|
| 1123 |
"label": r.label or "LinkedIn account",
|
| 1124 |
+
"display_name": _extract_unipile_identity(r.metadata_json or {})[0] or (r.label or "LinkedIn account"),
|
| 1125 |
+
"avatar_url": _extract_unipile_identity(r.metadata_json or {})[1] or "",
|
| 1126 |
"provider": r.provider,
|
| 1127 |
"unipile_account_id": r.unipile_account_id,
|
| 1128 |
"status": r.status or "unknown",
|
|
|
|
| 1192 |
raise HTTPException(status_code=400, detail="Missing name token")
|
| 1193 |
if not unipile_account_id:
|
| 1194 |
return {"ok": True, "ignored": True, "reason": "missing_account_id"}
|
| 1195 |
+
account_profile = _try_fetch_unipile_account_profile(unipile_account_id)
|
| 1196 |
+
merged_meta = {}
|
| 1197 |
+
if isinstance(body, dict):
|
| 1198 |
+
merged_meta.update(body)
|
| 1199 |
+
if isinstance(account_profile, dict) and account_profile:
|
| 1200 |
+
merged_meta["account_profile"] = account_profile
|
| 1201 |
|
| 1202 |
state = (
|
| 1203 |
db.query(UnipileHostedAuthState)
|
|
|
|
| 1228 |
existing.label = state.label or existing.label
|
| 1229 |
existing.status = status or existing.status
|
| 1230 |
existing.auth_mode = "hosted"
|
| 1231 |
+
existing.metadata_json = merged_meta or body
|
| 1232 |
existing.updated_at = datetime.utcnow()
|
| 1233 |
else:
|
| 1234 |
db.add(
|
|
|
|
| 1240 |
unipile_account_id=unipile_account_id,
|
| 1241 |
status=status or "connected",
|
| 1242 |
auth_mode="hosted",
|
| 1243 |
+
metadata_json=merged_meta or body,
|
| 1244 |
)
|
| 1245 |
)
|
| 1246 |
state.used_at = datetime.utcnow()
|
|
|
|
| 1279 |
account_id = _safe_str((response or {}).get("account", {}).get("id") if isinstance(response, dict) else "")
|
| 1280 |
if not account_id:
|
| 1281 |
raise HTTPException(status_code=400, detail="UniPile did not return an account_id")
|
| 1282 |
+
account_profile = _try_fetch_unipile_account_profile(account_id)
|
| 1283 |
+
merged_meta = response if isinstance(response, dict) else {"raw": str(response)}
|
| 1284 |
+
if isinstance(merged_meta, dict) and account_profile:
|
| 1285 |
+
merged_meta["account_profile"] = account_profile
|
| 1286 |
|
| 1287 |
db_row = UnipileAccount(
|
| 1288 |
tenant_id=t.tenant_id,
|
|
|
|
| 1292 |
unipile_account_id=account_id,
|
| 1293 |
status=_safe_str(response.get("status") if isinstance(response, dict) else "") or "connected",
|
| 1294 |
auth_mode=body.auth_mode,
|
| 1295 |
+
metadata_json=merged_meta,
|
| 1296 |
)
|
| 1297 |
t.db.add(db_row)
|
| 1298 |
t.db.commit()
|
|
|
|
| 1337 |
"unipile_account_label": a.label if a else "",
|
| 1338 |
"created_at": c.created_at.isoformat() if c.created_at else None,
|
| 1339 |
"executed_at": c.executed_at.isoformat() if c.executed_at else None,
|
| 1340 |
+
"execution_result": c.execution_result,
|
| 1341 |
}
|
| 1342 |
for c, a in rows
|
| 1343 |
]
|
|
|
|
| 1426 |
GeneratedSequence.product == campaign.name,
|
| 1427 |
).delete()
|
| 1428 |
|
| 1429 |
+
linkedin_profiles_detected = 0
|
| 1430 |
for idx, row in df.iterrows():
|
| 1431 |
row_dict = row.to_dict()
|
| 1432 |
sanitized_raw_data = {str(k): _json_safe(v) for k, v in row_dict.items()}
|
| 1433 |
+
li_raw = _pick_linkedin_url(sanitized_raw_data)
|
| 1434 |
+
if _linkedin_public_identifier(li_raw):
|
| 1435 |
+
linkedin_profiles_detected += 1
|
| 1436 |
contact = Contact(
|
| 1437 |
tenant_id=t.tenant_id,
|
| 1438 |
file_id=file_id,
|
|
|
|
| 1452 |
campaign.status = "draft"
|
| 1453 |
campaign.updated_at = datetime.utcnow()
|
| 1454 |
db.commit()
|
| 1455 |
+
return {
|
| 1456 |
+
"file_id": file_id,
|
| 1457 |
+
"contact_count": contact_count,
|
| 1458 |
+
"linkedin_profiles_detected": linkedin_profiles_detected,
|
| 1459 |
+
"message": "Campaign CSV uploaded",
|
| 1460 |
+
}
|
| 1461 |
|
| 1462 |
|
| 1463 |
@app.post("/api/linkedin-campaigns/{campaign_id}/generate")
|
|
|
|
| 1582 |
t: TenantContext = Depends(get_tenant_context),
|
| 1583 |
):
|
| 1584 |
"""
|
| 1585 |
+
Execute LinkedIn campaign via UniPile.
|
| 1586 |
+
|
| 1587 |
+
Sends a connection request using POST /users/invite with the first generated step as the
|
| 1588 |
+
invite note (visible under LinkedIn → My Network → Manage invitations → Sent).
|
| 1589 |
+
|
| 1590 |
+
UniPile expects the public /in/{{slug}} identifier plus account_id on profile lookup — not a raw URL path.
|
| 1591 |
+
|
| 1592 |
+
If the invite cannot be sent (e.g. already connected), falls back to a 1:1 LinkedIn DM via chats API.
|
| 1593 |
"""
|
| 1594 |
db = t.db
|
| 1595 |
campaign = (
|
|
|
|
| 1638 |
.order_by(GeneratedSequence.sequence_id.asc())
|
| 1639 |
.all()
|
| 1640 |
)
|
| 1641 |
+
if not seq_rows:
|
| 1642 |
+
raise HTTPException(
|
| 1643 |
+
status_code=400,
|
| 1644 |
+
detail="Generate LinkedIn sequences before executing the campaign.",
|
| 1645 |
+
)
|
| 1646 |
msg_by_seq = {r.sequence_id: r for r in seq_rows}
|
| 1647 |
|
| 1648 |
+
invite_sent = 0
|
| 1649 |
+
dm_sent = 0
|
| 1650 |
+
dry_preview = 0
|
| 1651 |
skipped = 0
|
| 1652 |
failed = 0
|
| 1653 |
errors = []
|
| 1654 |
attempts = []
|
| 1655 |
|
| 1656 |
+
acc_uid = account.unipile_account_id
|
| 1657 |
+
|
| 1658 |
+
def _contact_label(cr: Contact) -> str:
|
| 1659 |
+
parts = [_safe_str(cr.first_name), _safe_str(cr.last_name)]
|
| 1660 |
+
lab = " ".join(p for p in parts if p).strip()
|
| 1661 |
+
return lab or "Contact"
|
| 1662 |
+
|
| 1663 |
for idx, c in enumerate(contacts, start=1):
|
| 1664 |
first_msg = msg_by_seq.get(idx)
|
| 1665 |
+
name_label = _contact_label(c)
|
| 1666 |
if not first_msg:
|
| 1667 |
skipped += 1
|
| 1668 |
+
attempts.append(
|
| 1669 |
+
{
|
| 1670 |
+
"contact_id": c.id,
|
| 1671 |
+
"name": name_label,
|
| 1672 |
+
"status": "skipped",
|
| 1673 |
+
"reason": "no_generated_message",
|
| 1674 |
+
}
|
| 1675 |
+
)
|
| 1676 |
continue
|
| 1677 |
+
|
| 1678 |
+
raw_li = _pick_linkedin_url(c.raw_data or {})
|
| 1679 |
+
url_disp = _normalize_linkedin_url(raw_li) or _safe_str(raw_li)
|
| 1680 |
+
slug = _linkedin_public_identifier(raw_li)
|
| 1681 |
+
|
| 1682 |
+
if not slug:
|
| 1683 |
skipped += 1
|
| 1684 |
errors.append(
|
| 1685 |
{
|
| 1686 |
"contact_id": c.id,
|
| 1687 |
"reason": "missing_linkedin_url",
|
| 1688 |
+
"message": "No usable LinkedIn profile URL (expected Apollo-style Person Linkedin Url or /in/{{slug}}).",
|
| 1689 |
+
}
|
| 1690 |
+
)
|
| 1691 |
+
attempts.append(
|
| 1692 |
+
{
|
| 1693 |
+
"contact_id": c.id,
|
| 1694 |
+
"name": name_label,
|
| 1695 |
+
"linkedin_url": url_disp,
|
| 1696 |
+
"status": "skipped",
|
| 1697 |
+
"reason": "missing_or_invalid_linkedin_url",
|
| 1698 |
}
|
| 1699 |
)
|
| 1700 |
continue
|
| 1701 |
+
|
| 1702 |
+
note = _linkedin_invite_note(first_msg.email_content or "")
|
| 1703 |
+
|
| 1704 |
if body.dry_run:
|
| 1705 |
+
dry_preview += 1
|
| 1706 |
+
attempts.append(
|
| 1707 |
+
{
|
| 1708 |
+
"contact_id": c.id,
|
| 1709 |
+
"name": name_label,
|
| 1710 |
+
"linkedin_url": url_disp,
|
| 1711 |
+
"public_identifier": slug,
|
| 1712 |
+
"status": "dry_run",
|
| 1713 |
+
"detail": "Dry run: no UniPile calls. Uncheck the Dry run checkbox to send connection invites.",
|
| 1714 |
+
"invite_note_preview": note[:180],
|
| 1715 |
+
}
|
| 1716 |
+
)
|
| 1717 |
continue
|
| 1718 |
+
|
| 1719 |
+
slug_enc = requests.utils.quote(slug, safe="")
|
| 1720 |
+
acc_enc = requests.utils.quote(acc_uid, safe="")
|
| 1721 |
+
st_prof, profile = _unipile_call("GET", f"/api/v1/users/{slug_enc}?account_id={acc_enc}")
|
| 1722 |
+
|
| 1723 |
+
if st_prof >= 400 or not isinstance(profile, dict):
|
| 1724 |
+
failed += 1
|
| 1725 |
+
detail = profile if isinstance(profile, dict) else {}
|
| 1726 |
+
msg = (
|
| 1727 |
+
detail.get("message")
|
| 1728 |
+
or detail.get("detail")
|
| 1729 |
+
or detail.get("error")
|
| 1730 |
+
or (json.dumps(detail)[:800] if detail else "profile lookup failed")
|
| 1731 |
+
)
|
| 1732 |
+
if isinstance(msg, (dict, list)):
|
| 1733 |
+
msg = json.dumps(msg)
|
| 1734 |
+
errors.append({"contact_id": c.id, "reason": "profile_lookup_failed", "message": str(msg)})
|
| 1735 |
+
attempts.append(
|
| 1736 |
+
{
|
| 1737 |
+
"contact_id": c.id,
|
| 1738 |
+
"name": name_label,
|
| 1739 |
+
"linkedin_url": url_disp,
|
| 1740 |
+
"public_identifier": slug,
|
| 1741 |
+
"status": "failed",
|
| 1742 |
+
"step": "resolve_profile",
|
| 1743 |
+
"detail": str(msg)[:500],
|
| 1744 |
+
}
|
| 1745 |
+
)
|
| 1746 |
+
continue
|
| 1747 |
+
|
| 1748 |
+
provider_id = _safe_str(profile.get("provider_id"))
|
| 1749 |
+
rel_hint = _safe_str(
|
| 1750 |
+
profile.get("network_distance") or profile.get("distance") or ""
|
| 1751 |
+
)
|
| 1752 |
+
if not rel_hint and isinstance(profile.get("relation"), dict):
|
| 1753 |
+
rel_hint = _safe_str((profile.get("relation") or {}).get("distance"))
|
| 1754 |
+
|
| 1755 |
+
if not provider_id:
|
| 1756 |
+
failed += 1
|
| 1757 |
+
errors.append(
|
| 1758 |
+
{
|
| 1759 |
+
"contact_id": c.id,
|
| 1760 |
+
"reason": "missing_provider_id",
|
| 1761 |
+
"message": "UniPile profile response had no provider_id.",
|
| 1762 |
+
}
|
| 1763 |
)
|
| 1764 |
+
attempts.append(
|
| 1765 |
+
{
|
| 1766 |
+
"contact_id": c.id,
|
| 1767 |
+
"name": name_label,
|
| 1768 |
+
"linkedin_url": url_disp,
|
| 1769 |
+
"public_identifier": slug,
|
| 1770 |
+
"status": "failed",
|
| 1771 |
+
"step": "resolve_profile",
|
| 1772 |
+
"detail": "provider_id missing from UniPile profile payload",
|
| 1773 |
+
}
|
| 1774 |
+
)
|
| 1775 |
+
continue
|
| 1776 |
+
|
| 1777 |
+
st_inv, inv_res = _unipile_call(
|
| 1778 |
+
"POST",
|
| 1779 |
+
"/api/v1/users/invite",
|
| 1780 |
+
{"provider_id": provider_id, "account_id": acc_uid, "message": note},
|
| 1781 |
+
)
|
| 1782 |
|
| 1783 |
+
if st_inv < 400:
|
| 1784 |
+
invite_sent += 1
|
| 1785 |
+
attempts.append(
|
| 1786 |
+
{
|
| 1787 |
+
"contact_id": c.id,
|
| 1788 |
+
"name": name_label,
|
| 1789 |
+
"linkedin_url": url_disp,
|
| 1790 |
+
"public_identifier": slug,
|
| 1791 |
+
"provider_id": provider_id,
|
| 1792 |
+
"status": "invite_sent",
|
| 1793 |
+
"network_distance": rel_hint or None,
|
| 1794 |
+
"connection_hint": rel_hint or None,
|
| 1795 |
+
}
|
| 1796 |
+
)
|
| 1797 |
+
continue
|
| 1798 |
+
|
| 1799 |
+
inv_err = ""
|
| 1800 |
+
if isinstance(inv_res, dict):
|
| 1801 |
+
inv_err = _safe_str(
|
| 1802 |
+
inv_res.get("message") or inv_res.get("detail") or inv_res.get("error") or ""
|
| 1803 |
+
)
|
| 1804 |
+
if isinstance(inv_res.get("errors"), list):
|
| 1805 |
+
inv_err = inv_err or json.dumps(inv_res.get("errors"))[:400]
|
| 1806 |
+
|
| 1807 |
+
try:
|
| 1808 |
chat = _unipile_request(
|
| 1809 |
"POST",
|
| 1810 |
"/api/v1/chats",
|
| 1811 |
+
{"account_id": acc_uid, "attendees": [provider_id]},
|
|
|
|
|
|
|
|
|
|
| 1812 |
)
|
| 1813 |
chat_id = _safe_str(chat.get("id") if isinstance(chat, dict) else "")
|
| 1814 |
if not chat_id:
|
| 1815 |
raise ValueError("UniPile chat id not found")
|
|
|
|
|
|
|
| 1816 |
_unipile_request(
|
| 1817 |
"POST",
|
| 1818 |
+
f"/api/v1/chats/{requests.utils.quote(chat_id, safe='')}/messages",
|
| 1819 |
+
{"text": first_msg.email_content or ""},
|
| 1820 |
+
)
|
| 1821 |
+
dm_sent += 1
|
| 1822 |
+
attempts.append(
|
| 1823 |
{
|
| 1824 |
+
"contact_id": c.id,
|
| 1825 |
+
"name": name_label,
|
| 1826 |
+
"linkedin_url": url_disp,
|
| 1827 |
+
"public_identifier": slug,
|
| 1828 |
+
"provider_id": provider_id,
|
| 1829 |
+
"status": "message_sent",
|
| 1830 |
+
"network_distance": rel_hint or None,
|
| 1831 |
+
"connection_hint": rel_hint or None,
|
| 1832 |
+
"fallback_from_invite_error": inv_err or None,
|
| 1833 |
+
"detail": "Connection invite was not sent (see fallback_from_invite_error); sent LinkedIn DM instead.",
|
| 1834 |
+
}
|
| 1835 |
)
|
|
|
|
|
|
|
| 1836 |
except Exception as e:
|
| 1837 |
failed += 1
|
| 1838 |
errors.append(
|
| 1839 |
{
|
| 1840 |
"contact_id": c.id,
|
| 1841 |
+
"reason": "invite_and_dm_failed",
|
| 1842 |
+
"message": f"invite: {inv_err}; dm: {str(e)}",
|
| 1843 |
+
}
|
| 1844 |
+
)
|
| 1845 |
+
attempts.append(
|
| 1846 |
+
{
|
| 1847 |
+
"contact_id": c.id,
|
| 1848 |
+
"name": name_label,
|
| 1849 |
+
"linkedin_url": url_disp,
|
| 1850 |
+
"public_identifier": slug,
|
| 1851 |
+
"status": "failed",
|
| 1852 |
+
"step": "invite_and_message",
|
| 1853 |
+
"invite_error": inv_err or None,
|
| 1854 |
+
"detail": str(e),
|
| 1855 |
}
|
| 1856 |
)
|
| 1857 |
|
| 1858 |
+
total_ok = dry_preview if body.dry_run else (invite_sent + dm_sent)
|
| 1859 |
result = {
|
| 1860 |
+
"dry_run": body.dry_run,
|
| 1861 |
+
"dry_run_preview_count": dry_preview,
|
| 1862 |
+
"linkedin_invites_sent": invite_sent,
|
| 1863 |
+
"direct_messages_sent": dm_sent,
|
| 1864 |
+
"sent": total_ok,
|
| 1865 |
"skipped": skipped,
|
| 1866 |
"failed": failed,
|
| 1867 |
+
"errors": errors[:200],
|
| 1868 |
+
"attempts": attempts[:500],
|
| 1869 |
+
"execution_kind": "linkedin_connection_invite",
|
| 1870 |
+
"help": "Invites appear under LinkedIn → My Network → Manage invitations → Sent. Dry run performs zero UniPile calls.",
|
| 1871 |
}
|
| 1872 |
campaign.status = "completed" if failed == 0 else "failed"
|
| 1873 |
campaign.execution_result = result
|
frontend/src/components/campaigns/LinkedinCampaignsTab.jsx
CHANGED
|
@@ -21,12 +21,17 @@ export default function LinkedinCampaignsTab() {
|
|
| 21 |
const [promptTemplate, setPromptTemplate] = useState(
|
| 22 |
'Write a concise LinkedIn message sequence (3 steps) personalized using first name, company, and title. Keep each message under 500 characters, human and non-spammy.'
|
| 23 |
);
|
| 24 |
-
|
|
|
|
| 25 |
|
| 26 |
const selectedCampaign = useMemo(
|
| 27 |
() => campaigns.find((c) => String(c.id) === String(selectedCampaignId)) || null,
|
| 28 |
[campaigns, selectedCampaignId]
|
| 29 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
const refreshAll = async () => {
|
| 32 |
const [accRes, cmpRes] = await Promise.all([
|
|
@@ -113,7 +118,12 @@ export default function LinkedinCampaignsTab() {
|
|
| 113 |
const data = await res.json().catch(() => ({}));
|
| 114 |
if (!res.ok) throw new Error(data.detail || 'CSV upload failed');
|
| 115 |
await refreshAll();
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
} catch (e) {
|
| 118 |
alert(e.message || 'CSV upload failed');
|
| 119 |
} finally {
|
|
@@ -156,7 +166,15 @@ export default function LinkedinCampaignsTab() {
|
|
| 156 |
const data = await res.json().catch(() => ({}));
|
| 157 |
if (!res.ok) throw new Error(data.detail || 'Execution failed');
|
| 158 |
await refreshAll();
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
} catch (e) {
|
| 161 |
alert(e.message || 'Execution failed');
|
| 162 |
} finally {
|
|
@@ -193,6 +211,38 @@ export default function LinkedinCampaignsTab() {
|
|
| 193 |
<div className="mt-3 text-sm text-slate-600">
|
| 194 |
Connected accounts: {accounts.length}
|
| 195 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
</div>
|
| 197 |
|
| 198 |
<div className="rounded-2xl border border-slate-200 bg-white p-5">
|
|
@@ -213,7 +263,7 @@ export default function LinkedinCampaignsTab() {
|
|
| 213 |
<option value="">Select LinkedIn account</option>
|
| 214 |
{accounts.map((a) => (
|
| 215 |
<option key={a.id} value={a.id}>
|
| 216 |
-
{a.label} ({a.status || 'unknown'})
|
| 217 |
</option>
|
| 218 |
))}
|
| 219 |
</select>
|
|
@@ -222,6 +272,11 @@ export default function LinkedinCampaignsTab() {
|
|
| 222 |
Create New
|
| 223 |
</Button>
|
| 224 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
<div className="mt-4">
|
| 226 |
<select
|
| 227 |
className="h-10 w-full rounded-md border border-slate-200 px-3 text-sm"
|
|
@@ -296,18 +351,95 @@ export default function LinkedinCampaignsTab() {
|
|
| 296 |
|
| 297 |
<div className="rounded-2xl border border-slate-200 bg-white p-5">
|
| 298 |
<h3 className="mb-3 text-lg font-semibold text-slate-800">5) Execute Campaign via UniPile API</h3>
|
| 299 |
-
<
|
| 300 |
-
<
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
</label>
|
| 307 |
<Button onClick={executeCampaign} disabled={!selectedCampaignId || busy}>
|
| 308 |
<Play className="mr-2 h-4 w-4" />
|
| 309 |
Execute Campaign
|
| 310 |
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
</div>
|
| 312 |
</div>
|
| 313 |
);
|
|
|
|
| 21 |
const [promptTemplate, setPromptTemplate] = useState(
|
| 22 |
'Write a concise LinkedIn message sequence (3 steps) personalized using first name, company, and title. Keep each message under 500 characters, human and non-spammy.'
|
| 23 |
);
|
| 24 |
+
/** When true, the backend never calls UniPile — use once to validate rows, then turn off to send invites. */
|
| 25 |
+
const [dryRun, setDryRun] = useState(false);
|
| 26 |
|
| 27 |
const selectedCampaign = useMemo(
|
| 28 |
() => campaigns.find((c) => String(c.id) === String(selectedCampaignId)) || null,
|
| 29 |
[campaigns, selectedCampaignId]
|
| 30 |
);
|
| 31 |
+
const selectedAccount = useMemo(
|
| 32 |
+
() => accounts.find((a) => String(a.id) === String(createForm.unipile_account_ref_id)) || null,
|
| 33 |
+
[accounts, createForm.unipile_account_ref_id]
|
| 34 |
+
);
|
| 35 |
|
| 36 |
const refreshAll = async () => {
|
| 37 |
const [accRes, cmpRes] = await Promise.all([
|
|
|
|
| 118 |
const data = await res.json().catch(() => ({}));
|
| 119 |
if (!res.ok) throw new Error(data.detail || 'CSV upload failed');
|
| 120 |
await refreshAll();
|
| 121 |
+
const li = data.linkedin_profiles_detected;
|
| 122 |
+
alert(
|
| 123 |
+
`Uploaded ${data.contact_count || 0} rows.${
|
| 124 |
+
li != null ? ` LinkedIn profile URLs detected on ${li} rows (Apollo “Person Linkedin Url”).` : ''
|
| 125 |
+
}`
|
| 126 |
+
);
|
| 127 |
} catch (e) {
|
| 128 |
alert(e.message || 'CSV upload failed');
|
| 129 |
} finally {
|
|
|
|
| 166 |
const data = await res.json().catch(() => ({}));
|
| 167 |
if (!res.ok) throw new Error(data.detail || 'Execution failed');
|
| 168 |
await refreshAll();
|
| 169 |
+
if (data.dry_run) {
|
| 170 |
+
alert(
|
| 171 |
+
`Dry run only — nothing was sent to LinkedIn or UniPile.\nContacts previewed: ${data.dry_run_preview_count ?? data.sent ?? 0}\n\nUncheck “Dry run” and execute again to send connection invites.`
|
| 172 |
+
);
|
| 173 |
+
} else {
|
| 174 |
+
alert(
|
| 175 |
+
`Done.\nConnection invites sent: ${data.linkedin_invites_sent ?? 0}\nDirect messages (fallback): ${data.direct_messages_sent ?? 0}\nSkipped: ${data.skipped}\nFailed: ${data.failed}`
|
| 176 |
+
);
|
| 177 |
+
}
|
| 178 |
} catch (e) {
|
| 179 |
alert(e.message || 'Execution failed');
|
| 180 |
} finally {
|
|
|
|
| 211 |
<div className="mt-3 text-sm text-slate-600">
|
| 212 |
Connected accounts: {accounts.length}
|
| 213 |
</div>
|
| 214 |
+
{accounts.length > 0 ? (
|
| 215 |
+
<div className="mt-3 flex flex-wrap gap-2">
|
| 216 |
+
{accounts.map((a) => {
|
| 217 |
+
const n = a.display_name || a.label || 'LinkedIn account';
|
| 218 |
+
const initials = n
|
| 219 |
+
.split(/\s+/)
|
| 220 |
+
.slice(0, 2)
|
| 221 |
+
.map((s) => s[0] || '')
|
| 222 |
+
.join('')
|
| 223 |
+
.toUpperCase();
|
| 224 |
+
return (
|
| 225 |
+
<div
|
| 226 |
+
key={a.id}
|
| 227 |
+
className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-xs text-slate-700"
|
| 228 |
+
>
|
| 229 |
+
{a.avatar_url ? (
|
| 230 |
+
<img
|
| 231 |
+
src={a.avatar_url}
|
| 232 |
+
alt={n}
|
| 233 |
+
className="h-5 w-5 rounded-full object-cover"
|
| 234 |
+
/>
|
| 235 |
+
) : (
|
| 236 |
+
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-violet-100 text-[10px] font-semibold text-violet-700">
|
| 237 |
+
{initials || 'LI'}
|
| 238 |
+
</span>
|
| 239 |
+
)}
|
| 240 |
+
<span className="max-w-[210px] truncate">{n}</span>
|
| 241 |
+
</div>
|
| 242 |
+
);
|
| 243 |
+
})}
|
| 244 |
+
</div>
|
| 245 |
+
) : null}
|
| 246 |
</div>
|
| 247 |
|
| 248 |
<div className="rounded-2xl border border-slate-200 bg-white p-5">
|
|
|
|
| 263 |
<option value="">Select LinkedIn account</option>
|
| 264 |
{accounts.map((a) => (
|
| 265 |
<option key={a.id} value={a.id}>
|
| 266 |
+
{(a.display_name || a.label || 'LinkedIn account')} ({a.status || 'unknown'})
|
| 267 |
</option>
|
| 268 |
))}
|
| 269 |
</select>
|
|
|
|
| 272 |
Create New
|
| 273 |
</Button>
|
| 274 |
</div>
|
| 275 |
+
{selectedAccount ? (
|
| 276 |
+
<div className="mt-3 rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
|
| 277 |
+
Using account: <strong>{selectedAccount.display_name || selectedAccount.label}</strong>
|
| 278 |
+
</div>
|
| 279 |
+
) : null}
|
| 280 |
<div className="mt-4">
|
| 281 |
<select
|
| 282 |
className="h-10 w-full rounded-md border border-slate-200 px-3 text-sm"
|
|
|
|
| 351 |
|
| 352 |
<div className="rounded-2xl border border-slate-200 bg-white p-5">
|
| 353 |
<h3 className="mb-3 text-lg font-semibold text-slate-800">5) Execute Campaign via UniPile API</h3>
|
| 354 |
+
<p className="mb-3 text-sm text-slate-600">
|
| 355 |
+
Sends a <strong>LinkedIn connection request</strong> using your first generated message as the invite note
|
| 356 |
+
(visible under LinkedIn → My Network → Manage invitations → Sent). If an invite cannot be sent (for example
|
| 357 |
+
you are already connected), the app falls back to a direct message when possible.
|
| 358 |
+
</p>
|
| 359 |
+
{dryRun ? (
|
| 360 |
+
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-950">
|
| 361 |
+
<strong>Dry run is on.</strong> No requests go to UniPile and nothing appears on LinkedIn — including no
|
| 362 |
+
invitations in “Sent”.
|
| 363 |
+
</div>
|
| 364 |
+
) : null}
|
| 365 |
+
<label className="mb-3 flex flex-col gap-1 text-sm text-slate-700 sm:flex-row sm:items-center sm:gap-2">
|
| 366 |
+
<span className="inline-flex items-center gap-2">
|
| 367 |
+
<input
|
| 368 |
+
type="checkbox"
|
| 369 |
+
checked={dryRun}
|
| 370 |
+
onChange={(e) => setDryRun(e.target.checked)}
|
| 371 |
+
/>
|
| 372 |
+
Dry run (preview only — no UniPile / LinkedIn activity)
|
| 373 |
+
</span>
|
| 374 |
</label>
|
| 375 |
<Button onClick={executeCampaign} disabled={!selectedCampaignId || busy}>
|
| 376 |
<Play className="mr-2 h-4 w-4" />
|
| 377 |
Execute Campaign
|
| 378 |
</Button>
|
| 379 |
+
|
| 380 |
+
{selectedCampaign?.execution_result?.attempts?.length ? (
|
| 381 |
+
<div className="mt-5 rounded-lg border border-slate-200 bg-slate-50/80 p-3">
|
| 382 |
+
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
| 383 |
+
<p className="text-sm font-semibold text-slate-800">Last run — per contact</p>
|
| 384 |
+
{selectedCampaign.execution_result?.dry_run ? (
|
| 385 |
+
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-900">
|
| 386 |
+
Dry run
|
| 387 |
+
</span>
|
| 388 |
+
) : null}
|
| 389 |
+
</div>
|
| 390 |
+
<p className="mb-2 text-xs text-slate-600">
|
| 391 |
+
{selectedCampaign.execution_result?.help ||
|
| 392 |
+
'Statuses: invite_sent = connection request submitted; message_sent = DM (invite not used); dry_run = preview only.'}
|
| 393 |
+
</p>
|
| 394 |
+
<div className="max-h-72 overflow-auto rounded-md border border-slate-200 bg-white">
|
| 395 |
+
<table className="w-full text-left text-xs">
|
| 396 |
+
<thead className="sticky top-0 bg-slate-100 text-slate-700">
|
| 397 |
+
<tr>
|
| 398 |
+
<th className="px-2 py-1.5 font-medium">Name</th>
|
| 399 |
+
<th className="px-2 py-1.5 font-medium">Profile</th>
|
| 400 |
+
<th className="px-2 py-1.5 font-medium">Status</th>
|
| 401 |
+
<th className="px-2 py-1.5 font-medium">Note</th>
|
| 402 |
+
</tr>
|
| 403 |
+
</thead>
|
| 404 |
+
<tbody>
|
| 405 |
+
{selectedCampaign.execution_result.attempts.map((row, i) => (
|
| 406 |
+
<tr key={`${row.contact_id}-${i}`} className="border-t border-slate-100">
|
| 407 |
+
<td className="px-2 py-1.5 align-top text-slate-800">{row.name || '—'}</td>
|
| 408 |
+
<td className="max-w-[200px] px-2 py-1.5 align-top">
|
| 409 |
+
<div className="truncate text-slate-600" title={row.linkedin_url || ''}>
|
| 410 |
+
{row.public_identifier || row.linkedin_url || '—'}
|
| 411 |
+
</div>
|
| 412 |
+
</td>
|
| 413 |
+
<td className="whitespace-nowrap px-2 py-1.5 align-top font-medium text-slate-800">
|
| 414 |
+
{row.status === 'invite_sent'
|
| 415 |
+
? 'Invite sent'
|
| 416 |
+
: row.status === 'message_sent'
|
| 417 |
+
? 'DM sent'
|
| 418 |
+
: row.status === 'dry_run'
|
| 419 |
+
? 'Dry run'
|
| 420 |
+
: row.status === 'skipped'
|
| 421 |
+
? 'Skipped'
|
| 422 |
+
: row.status === 'failed'
|
| 423 |
+
? 'Failed'
|
| 424 |
+
: row.status || '—'}
|
| 425 |
+
</td>
|
| 426 |
+
<td className="max-w-[240px] px-2 py-1.5 align-top text-slate-600">
|
| 427 |
+
{row.connection_hint || row.network_distance
|
| 428 |
+
? `Distance: ${row.connection_hint || row.network_distance}. `
|
| 429 |
+
: ''}
|
| 430 |
+
{row.detail || row.invite_error || row.fallback_from_invite_error || row.reason || ''}
|
| 431 |
+
</td>
|
| 432 |
+
</tr>
|
| 433 |
+
))}
|
| 434 |
+
</tbody>
|
| 435 |
+
</table>
|
| 436 |
+
</div>
|
| 437 |
+
<div className="mt-2 text-xs text-slate-500">
|
| 438 |
+
LinkedIn does not expose “accepted” here in real time; check My Network for new connections. UniPile
|
| 439 |
+
may include network distance on the profile response when available.
|
| 440 |
+
</div>
|
| 441 |
+
</div>
|
| 442 |
+
) : null}
|
| 443 |
</div>
|
| 444 |
</div>
|
| 445 |
);
|