@teamlearners/clawops 0.32.0 → 0.33.0

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.
package/README.md CHANGED
@@ -372,6 +372,192 @@ for await (const m of (await client.messages.list()).autoPagingIter()) {
372
372
  const detail = await client.messages.get('MG0123456789abcdef');
373
373
  ```
374
374
 
375
+ ### 솔라피(SOLAPI) 호환 — 문자만 ClawOps 로
376
+
377
+ 이미 솔라피 SDK 로 작성된 코드를 **그대로 두고** 문자(SMS/LMS/MMS)만 ClawOps 로 보냅니다.
378
+ 알림톡·친구톡·RCS 는 기존 솔라피 계정으로 계속 나갑니다.
379
+
380
+ ```bash
381
+ npm install @teamlearners/clawops solapi
382
+ ```
383
+
384
+ 바꾸는 곳은 **인스턴스를 만드는 한 줄**뿐입니다.
385
+
386
+ ```typescript
387
+ import { ClawOps } from '@teamlearners/clawops';
388
+ import { ClawOpsMessageService } from '@teamlearners/clawops/solapi';
389
+ import { SolapiMessageService } from 'solapi';
390
+
391
+ const messageService = new ClawOpsMessageService({
392
+ clawops: new ClawOps({ apiKey: process.env.CLAWOPS_API_KEY, accountId: process.env.CLAWOPS_ACCOUNT_ID }),
393
+ solapi: new SolapiMessageService(SOLAPI_KEY, SOLAPI_SECRET), // 알림톡을 계속 쓸 때만
394
+ from: '07052358010', // ClawOps 에 등록된 번호
395
+ });
396
+
397
+ // 이 아래 호출부는 기존 코드 그대로입니다
398
+ await messageService.send({ to: '01012345678', from: '07052358010', text: '인증번호는 123456 입니다' });
399
+ ```
400
+
401
+ `messageService` 의 타입은 `SolapiMessageService` 와 동일해서 기존 코드의 타입 자리에 그대로 들어갑니다.
402
+ `send` 만 가로채고 `getBalance()`·`getKakaoChannels()` 같은 나머지 메서드는 주입한 솔라피 인스턴스로 그대로 전달합니다.
403
+ 원본 인스턴스는 수정하지 않습니다.
404
+
405
+ | 메시지 | 어디로 |
406
+ |---|---|
407
+ | `SMS` / `LMS` / `MMS` | **ClawOps** |
408
+ | `ATA`(알림톡) · `CTA`/`CTI`(친구톡) · `RCS_*` · `NSA` · `FAX` · `VOICE` · `BMS_*` | 솔라피 (요청을 손대지 않고 그대로 전달) |
409
+ | `type` 미지정 | `kakaoOptions`·`rcsOptions` 같은 **vendor 옵션이 있으면 솔라피**, 없으면 ClawOps |
410
+
411
+ 솔라피는 `type` 을 필수로 요구하지 않고 vendor 옵션으로 추론합니다. 그래서 우리도 타입 문자열이
412
+ 아니라 **실제로 실린 옵션**으로 가릅니다 — `send({ to, from, kakaoOptions })` 처럼 `type` 을 생략한
413
+ 알림톡도 솔라피로 갑니다.
414
+
415
+ `type` 을 `SMS`/`LMS` 로 지정하면 그대로 따르고, 지정하지 않고 vendor 옵션도 없으면 서버와 같은
416
+ 규칙으로 고릅니다 — `subject` 가 있으면 `lms`, 본문이 200 byte(UTF-8)를 넘으면 `lms`, 아니면 `sms`.
417
+
418
+ `imageId` 는 솔라피에 업로드된 파일 ID 라 ClawOps 로 옮길 수 없습니다. 이미지가 붙은 메시지는
419
+ 조용히 텍스트만 보내지 않고 **에러를 던집니다**. 첨부가 필요하면 `client.messages.create` 의
420
+ `mediaUrl` 로 직접 발송하십시오. 첨부 없는 `MMS` 는 위 규칙에 따라 `sms`/`lms` 로 나갑니다.
421
+
422
+ 거절은 모두 `SolapiBridgeError`(`ClawOpsError` 하위)로 던지므로 SDK 의 다른 에러와 함께 잡을 수 있습니다.
423
+
424
+ #### 알림톡 실패 시 문자로 대체발송
425
+
426
+ 솔라피의 대체발송은 **솔라피에 등록된 발신번호**가 있어야 동작합니다. 그 번호가 없으면
427
+ 알림톡이 실패해도 문자가 나가지 않습니다. 이때 대체발송을 ClawOps 가 대신합니다.
428
+
429
+ 의도는 솔라피 API 의 값 그대로 읽습니다 — 별도 옵션이 필요 없습니다.
430
+
431
+ | 보낸 값 | 동작 |
432
+ |---|---|
433
+ | `from` 있음 + `disableSms` 생략/`false` | 알림톡 실패 시 **ClawOps 문자로 대체발송** |
434
+ | `disableSms: true` | 대체발송하지 않음 |
435
+ | `from` 없음 | 대체발송하지 않음 (솔라피 규칙과 동일) |
436
+
437
+ 솔라피로 요청을 넘길 때 두 가지를 조정합니다.
438
+
439
+ - `from` 을 **제외**합니다. 솔라피에 등록되지 않은 번호가 실리면 알림톡 자체가 접수 거부됩니다.
440
+ - `disableSms` 를 `true` 로 보냅니다. 솔라피가 중복으로 문자를 발송하지 않도록.
441
+
442
+ 대체발송 문구는 다음 순서로 정해집니다.
443
+
444
+ 1. `customFields` 에 지정한 문구 (기본 키 `clawopsFallbackText`) — 문자 전용 문구를 직접 넣을 때
445
+ 2. **`kakaoOptions.templateId` 가 있으면** 그 템플릿 본문을 조회해 `variables` 로 치환 — 알림톡은 보통 `text` 없이 보내므로 기본 경로입니다. `type` 을 생략해도 동작합니다
446
+ 3. 그 밖에는 `text` 가 본문입니다
447
+
448
+ ```typescript
449
+ await messageService.send({
450
+ to: '01012345678',
451
+ from: '07052358010',
452
+ type: 'ATA',
453
+ kakaoOptions: { pfId: 'KA01PF...', templateId: 'TPL_001', variables: { 고객명: '홍길동', 주문번호: 'A-1024' } },
454
+ // 문자로 나갈 때만 다른 문구를 쓰고 싶다면
455
+ customFields: { clawopsFallbackText: '[상점명] 홍길동님 주문 A-1024 가 접수되었습니다.' },
456
+ });
457
+ ```
458
+
459
+ 치환되지 않은 변수(`#{...}`)가 남으면 **발송하지 않고** `onBlocked` 로 알립니다.
460
+ `#{주문번호}` 가 그대로 찍힌 문자가 나가는 것을 막기 위해서입니다.
461
+
462
+ ```typescript
463
+ const messageService = new ClawOpsMessageService({
464
+ clawops, solapi, from: '07052358010',
465
+ fallback: {
466
+ enabled: true, // 기본 true. false 면 대체발송하지 않는다
467
+ field: 'clawopsFallbackText', // customFields 키를 바꾸고 싶을 때
468
+ onFallback: (e) => logger.info({ to: e.to, source: e.source }, '문자로 대체발송'),
469
+ onBlocked: (e) => logger.warn({ to: e.to, reason: e.reason }, '대체발송 못 함'),
470
+ },
471
+ });
472
+ ```
473
+
474
+ 대체발송된 건은 솔라피와 같은 의미로 `groupInfo.count.sentReplacement` 에 집계됩니다.
475
+
476
+ #### 발송 실패(3XXX)까지 대체발송 — `mode: 'sweep'`
477
+
478
+ 위까지는 **접수 실패**만 다룹니다. 그런데 실제로 대체발송이 필요한 건 대부분 접수 이후에
479
+ 판명됩니다 — 수신자가 카카오톡을 쓰지 않거나(`3104`) 알림톡을 차단한 경우(`3107`)는
480
+ 접수가 성공하고 **이통사 리포트에서만** 드러납니다.
481
+
482
+ ```typescript
483
+ const messageService = new ClawOpsMessageService({
484
+ clawops, solapi, from: '07052358010',
485
+ fallback: {
486
+ enabled: true,
487
+ mode: 'sweep', // 리포트를 주기적으로 훑는다
488
+ intervalMs: 5 * 60_000, // 기본 5분
489
+ lookbackMs: 60 * 60_000, // 커서가 없을 때 거슬러 볼 구간. 기본 1시간
490
+ on: ['3104', '3107', '3102'], // 기본값 (수신자 사유만)
491
+ types: ['ATA'], // 훑을 타입. 친구톡까지 보려면 ['ATA','CTA','CTI']
492
+ onFallback: (e) => logger.info({ messageId: e.messageId, statusCode: e.statusCode }, '대체발송'),
493
+ onBlocked: (e) => logger.warn({ messageId: e.messageId, reason: e.reason }, '대체발송 못 함'),
494
+ },
495
+ });
496
+ ```
497
+
498
+ **켜기만 하면 됩니다.** 커서·저장소·크론이 필요 없습니다. 발송할 때 `customFields` 에 마커를
499
+ 심어 두고, 스윕이 그 마커가 있는 실패 건만 골라 문자를 보냅니다. 고객이 솔라피로 직접 보낸
500
+ 알림톡은 마커가 없어 건드리지 않습니다.
501
+
502
+ 같은 건을 두 번 보내지 않도록 두 장치가 다르게 동작합니다 — **커서와 처리 집합**이 재조회를
503
+ 막고(정상 운영 중에는 요청이 아예 안 나갑니다), **멱등키**가 프로세스 재시작처럼 커서가 비는
504
+ 순간의 안전망입니다.
505
+
506
+ 알림톡 실패는 `3XXX` 로만 판정합니다 — 모르는 코드가 오면 실패로 단정하지 않고 다음 스윕에서 다시 봅니다.
507
+ ClawOps 가 대체 문자를 거절하면 `onBlocked` 에 `reason: 'send_rejected'` 로 알리고, 다음 스윕이
508
+ 다시 시도합니다(멱등키가 같아 중복 발송은 되지 않습니다).
509
+
510
+ 기본 대상은 **수신자 사유만**입니다. 설정 오류(`3101` 발신프로필 무효 · `3105` 미등록 템플릿 ·
511
+ `3106` 유효하지 않은 채널)를 문자로 덮으면 알림톡이 깨진 걸 모르게 되고, `3108`(발송 가능 시간
512
+ 아님)을 대체하면 21시 이후 발송이 되어 야간 규제에 걸립니다. 이런 건은 보내지 않고
513
+ `onBlocked` 에 `reason: 'code_not_eligible'` 로 **알리기만** 합니다.
514
+
515
+ **상주 프로세스가 없다면**(서버리스·크론) 같은 일을 하는 함수를 직접 부르십시오.
516
+
517
+ ```typescript
518
+ import { sweepFailedAlimtalk } from '@teamlearners/clawops/solapi';
519
+
520
+ const result = await sweepFailedAlimtalk({
521
+ clawops, solapi, from: '07052358010',
522
+ cursor: await loadCursor(), // 없으면 lookback 만큼 거슬러 본다
523
+ });
524
+ await saveCursor(result.cursor); // 직렬화 가능한 값만 담겨 있다
525
+ ```
526
+
527
+ #### 문자 전용 모드
528
+
529
+ 솔라피를 아예 쓰지 않는다면 `solapi` 를 넘기지 않아도 됩니다.
530
+ 이때는 타입에서도 `send` 만 노출되어, 솔라피 전용 메서드를 부르면 **컴파일 단계에서** 막힙니다.
531
+
532
+ ```typescript
533
+ const messageService = new ClawOpsMessageService({ clawops, from: '07052358010' });
534
+ await messageService.send({ to: '01012345678', text: '문자' });
535
+ ```
536
+
537
+ #### 알아두어야 할 것
538
+
539
+ - **다음 경우에는 요청을 그대로 솔라피에 넘깁니다** — 대체발송 마커를 심지 않고 `from`·`disableSms`
540
+ 도 건드리지 않으므로, 솔라피 자체 대체발송이 그대로 동작합니다: `fallback` 을 껐을 때,
541
+ 브랜드메시지(`kakaoOptions.bms`), 그리고 `customFields` 가 이미 10개(솔라피 상한)를 다 썼을 때.
542
+ - **`mode: 'sweep'` 은 한 인스턴스에서만 켜십시오.** 여러 프로세스가 동시에 스윕하면 같은 건을
543
+ 함께 집어 문자가 두 번 나갈 수 있습니다 — 멱등키는 순차 재시도를 막을 뿐, 완전히 동시에 들어온
544
+ 두 요청은 통과합니다. 다중 인스턴스라면 크론에서 `sweepFailedAlimtalk()` 를 한 번만 부르십시오.
545
+ - 스윕은 알림톡 리포트가 확정된 뒤에 동작하므로 **대체발송에 지연이 있습니다**(스윕 주기 + 리포트
546
+ 확정 시간). 즉시 도달해야 하는 메시지는 처음부터 문자로 보내십시오.
547
+ - **예약 발송·중복 제거는 지원하지 않습니다.** ClawOps 로 가는 메시지가 있는데 `scheduledDate`
548
+ 또는 `allowDuplicates: false` 가 오면 조용히 무시하지 않고 에러를 던집니다.
549
+ - 문자 발송이 개별적으로 실패하면 나머지 건은 그대로 접수되고, 실패 건만
550
+ `failedMessageList` 에 `statusCode: 'CLAWOPS'` 로 담깁니다. 솔라피 상태 코드가 아닙니다.
551
+ - 문자만 보낸 요청의 `groupInfo` 중 `balance`·`point`·`price`·`countForCharge` 는 ClawOps 에
552
+ 대응 개념이 없어 **0/빈 값**이고, `groupId` 는 `CLAWOPS-` 로 시작하는 자체 값이라
553
+ `getGroupMessages()` 로 조회되지 않습니다.
554
+ - 알림톡이 포함된 요청은 솔라피가 준 `groupInfo` 를 이어받되, 접수 집계
555
+ (`total`·`registeredSuccess`·`registeredFailed`)는 우리가 보낸 문자까지 합쳐 다시 셉니다.
556
+ 발송 단계 집계(`sentSuccess` 등)는 솔라피 값 그대로입니다.
557
+ - 전화번호는 하이픈 유무와 무관하게 처리합니다(`010-1111-2222` 로 보내도 됩니다).
558
+ - 광고성 알림톡을 문자로 대체발송하면 광고 문자에 요구되는 표기·수신거부 안내·야간 발송 제한이
559
+ 적용됩니다. 해당 템플릿은 `disableSms: true` 로 두거나 `customFields` 로 문구를 조정하십시오.
560
+
375
561
  ### 멀티 계정 접근
376
562
 
377
563
  ```typescript
@@ -480,7 +666,7 @@ const client = new ClawOps({
480
666
 
481
667
  | 언어 | 패키지 | 저장소 |
482
668
  |------|--------|--------|
483
- | Python | [`clawops`](https://pypi.org/project/clawops/) | [clawops-python](https://github.com/learners-superpumped/clawops-python) |
669
+ | Python | [`clawops`](https://pypi.org/project/clawops/) | [sdk-python](https://github.com/clawopshq/sdk-python) |
484
670
 
485
671
  ## 요구사항
486
672
 
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
- var chunkHKXRJRAW_cjs = require('../chunk-HKXRJRAW.cjs');
3
+ var chunkZCUZCZTF_cjs = require('../chunk-ZCUZCZTF.cjs');
4
+ var chunkTVDUOKJD_cjs = require('../chunk-TVDUOKJD.cjs');
4
5
  var chunkQD2ZY4UP_cjs = require('../chunk-QD2ZY4UP.cjs');
5
6
  var fs = require('fs');
6
7
  var path = require('path');
@@ -728,7 +729,7 @@ var MAX_ERROR_MESSAGE_LENGTH = 200;
728
729
  function getSdkInfo() {
729
730
  return {
730
731
  name: "clawops-node",
731
- version: chunkHKXRJRAW_cjs.VERSION,
732
+ version: chunkZCUZCZTF_cjs.VERSION,
732
733
  runtime: `node/${process.versions.node}`,
733
734
  os: `${process.platform}/${os__default.default.arch()}`
734
735
  };
@@ -874,7 +875,12 @@ var CallSession = class {
874
875
  if (isConnected) this._isTransportConnected = isConnected;
875
876
  this._status = "active";
876
877
  }
877
- /** Send PCM16 or ulaw audio to the caller. */
878
+ /**
879
+ * Send G.711 μ-law audio (8kHz, mono) to the caller.
880
+ *
881
+ * NOT PCM16. The transport applies μ-law gain and forwards the bytes as-is,
882
+ * so PCM16 input is reinterpreted as μ-law and plays as noise. Convert first.
883
+ */
878
884
  sendAudio(audio) {
879
885
  if (this._sendAudioFn) {
880
886
  this._sendAudioFn(audio);
@@ -1468,7 +1474,7 @@ var ClawOpsAgent = class _ClawOpsAgent {
1468
1474
  constructor(options) {
1469
1475
  this._apiKey = options.apiKey ?? process.env["CLAWOPS_API_KEY"] ?? "";
1470
1476
  this._accountId = options.accountId ?? process.env["CLAWOPS_ACCOUNT_ID"] ?? "";
1471
- this._baseUrl = options.baseUrl ?? chunkHKXRJRAW_cjs.DEFAULT_BASE_URL;
1477
+ this._baseUrl = options.baseUrl ?? chunkZCUZCZTF_cjs.DEFAULT_BASE_URL;
1472
1478
  this._fromNumber = options.from;
1473
1479
  this._session = options.session;
1474
1480
  this._recording = options.recording ?? false;
@@ -1492,7 +1498,7 @@ var ClawOpsAgent = class _ClawOpsAgent {
1492
1498
  }
1493
1499
  static _validateGain(name, gain) {
1494
1500
  if (typeof gain !== "number" || !Number.isFinite(gain) || gain < 0) {
1495
- throw new chunkHKXRJRAW_cjs.AgentError(`${name}=${gain} must be a finite number >= 0`);
1501
+ throw new chunkTVDUOKJD_cjs.AgentError(`${name}=${gain} must be a finite number >= 0`);
1496
1502
  }
1497
1503
  return gain;
1498
1504
  }
@@ -1506,7 +1512,7 @@ var ClawOpsAgent = class _ClawOpsAgent {
1506
1512
  tool(nameOrTool, description, parameters, handler) {
1507
1513
  if (typeof nameOrTool === "string") {
1508
1514
  if (!description || !parameters || !handler) {
1509
- throw new chunkHKXRJRAW_cjs.AgentError(
1515
+ throw new chunkTVDUOKJD_cjs.AgentError(
1510
1516
  "tool(name, description, parameters, handler) requires all arguments."
1511
1517
  );
1512
1518
  }
@@ -1542,10 +1548,10 @@ var ClawOpsAgent = class _ClawOpsAgent {
1542
1548
  async connect() {
1543
1549
  if (this._controlWs) return;
1544
1550
  if (!this._apiKey) {
1545
- throw new chunkHKXRJRAW_cjs.AgentError("API key is required. Set CLAWOPS_API_KEY or pass apiKey option.");
1551
+ throw new chunkTVDUOKJD_cjs.AgentError("API key is required. Set CLAWOPS_API_KEY or pass apiKey option.");
1546
1552
  }
1547
1553
  if (!this._accountId) {
1548
- throw new chunkHKXRJRAW_cjs.AgentError(
1554
+ throw new chunkTVDUOKJD_cjs.AgentError(
1549
1555
  "Account ID is required. Set CLAWOPS_ACCOUNT_ID or pass accountId option."
1550
1556
  );
1551
1557
  }
@@ -1569,7 +1575,7 @@ var ClawOpsAgent = class _ClawOpsAgent {
1569
1575
  } catch {
1570
1576
  }
1571
1577
  } catch (err) {
1572
- throw new chunkHKXRJRAW_cjs.AgentConnectionError(
1578
+ throw new chunkTVDUOKJD_cjs.AgentConnectionError(
1573
1579
  `Failed to connect to ClawOps: ${err instanceof Error ? err.message : String(err)}`
1574
1580
  );
1575
1581
  }
@@ -1634,7 +1640,7 @@ var ClawOpsAgent = class _ClawOpsAgent {
1634
1640
  });
1635
1641
  if (resp.status !== 201) {
1636
1642
  const error = await resp.json();
1637
- throw new chunkHKXRJRAW_cjs.AgentError(`\uBC1C\uC2E0 \uC2E4\uD328 (${resp.status}): ${error["error"] ?? ""}`);
1643
+ throw new chunkTVDUOKJD_cjs.AgentError(`\uBC1C\uC2E0 \uC2E4\uD328 (${resp.status}): ${error["error"] ?? ""}`);
1638
1644
  }
1639
1645
  const data = await resp.json();
1640
1646
  const callSession = new CallSession({