sendgo 1.2.1 → 1.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4931577496e9234373f7658f0492d1870cd947ed6d4bc1b554c6408cd509474
4
- data.tar.gz: 907c69a850ea3d3f766762b5432ec88aee3235e0749ecf2510c1f6b7c4a7a2df
3
+ metadata.gz: e838cbd6f30a61b781b74b37eb2a088710b8a6a41594b0fcbd6eb2a874dd2849
4
+ data.tar.gz: ca411988c2a81bc7957a339690392a8de14b1b0e710332305787be9a65f23302
5
5
  SHA512:
6
- metadata.gz: d84d2b4543c3ae6a284a50a506e8ff355a89bfa7ec7cea03b2c412980b472687eb28fe09d9c70e8652ec8843cdcda6978b2fcd835c392f9ab0b27171320e59f3
7
- data.tar.gz: 491cf6a525fb6423a1a314203af5fa412f3b331d7cfe58c5b13089c465a7083aa6bcd35e99f1c65a79aa270e6c6965d8a11628314b3cf933d477f0e2d03690f2
6
+ metadata.gz: a1d58de2204cb3471c287a8f4485a54cf53100b8267585e7189632883d2542cc4a5a293cce5a33c9351bfd74fcf84066e27510caba6bb5d61e784f7592c03911
7
+ data.tar.gz: 90d2b439c9f969a1d13bb302694cdc6b3f0a9b01466f2b7488aa25c286a0bada3b96c3f709cdad680296e2d6e1256a753e217e6ba875fd2f0f39f70e57b42ba7
data/README.md CHANGED
@@ -360,8 +360,290 @@ sendgo.short_url.deactivate(code) # 리다이렉트만 중지, 통계는 남
360
360
  `stats` 는 일별 추이(`daily`)와 디바이스(`byDevice`)·유입경로(`byReferer`)·국가(`byCountry`)별
361
361
  분해를 반환합니다. 일별 추이는 사전 집계 표에서 읽으므로 클릭이 많아도 응답 시간이 일정합니다.
362
362
 
363
+ ## 관리 API — 채널·템플릿·발신번호 등록 (v2 전용)
364
+
365
+ 발송은 처음부터 API였지만 **등록과 심사는 콘솔에서만** 되던 것들이 있었습니다.
366
+ 1.3.0 부터 그 작업도 코드로 처리합니다.
367
+
368
+ | 서비스 | 하는 일 | 계정 |
369
+ | --- | --- | --- |
370
+ | `client.kakao_senders` | 카카오 채널 인증·등록·동기화, 브랜드메시지 M/N 신청 | 기업 |
371
+ | `client.notice_templates` | 알림톡 템플릿 CRUD, 검수 요청·취소, 승인 취소, 휴면 해제 | 기업 |
372
+ | `client.brand_templates` | 브랜드메시지(구 친구톡) 템플릿 CRUD, 동기화, 가져오기 | 기업 |
373
+ | `client.sender_registration` | 발신번호 등록 신청, 중복 확인, 유형 안내 | 개인·기업 |
374
+ | `client.message_templates` | 문자 상용구 템플릿 CRUD | 개인·기업 |
375
+ | `client.kakao_images` | 카카오 이미지 업로드 — 템플릿용 URL 발급 | 기업 |
376
+ | `client.rejected_numbers` | 수신거부(080) 번호 조회 | 개인·기업 |
377
+ | `client.webhook` | 이벤트 웹훅 구독 — 심사 결과 수신 | 개인·기업 |
378
+
379
+ > **sendgo.io 콘솔에 들어올 일이 없습니다.** 고객의 채널·발신번호·템플릿을
380
+ > 여러분 화면만으로 끝까지 처리할 수 있습니다. 휴대폰 발신번호는 콘솔의 PASS
381
+ > 본인인증 대신 **신분증 사본(`identityDocument`)을 받아 sendgo 운영자가 대신
382
+ > 심사**합니다.
383
+ >
384
+ > 사람이 개입하는 지점은 **카카오 채널 인증번호 하나**뿐이고, 그마저도
385
+ > 여러분 화면에서 입력받으면 됩니다 — 카카오가 관리자 휴대폰으로 직접 보내는
386
+ > 확인이라 없앨 수 없습니다.
387
+ >
388
+ > 심사가 붙는 것들은 **비동기**입니다. 등록 호출이 성공했다는 건 "접수됐다"는
389
+ > 뜻이지 "쓸 수 있다"는 뜻이 아닙니다 — 웹훅을 구독해 결과를 받으세요.
390
+
391
+ 인자는 snake_case 로 씁니다 — SDK 가 camelCase 로 변환해 보냅니다.
392
+
393
+ ### 카카오 채널 등록
394
+
395
+ ```ruby
396
+ # 1단계 — 카카오가 관리자 휴대폰으로 인증번호를 SMS 발송한다 (응답에 번호는 없다)
397
+ client.kakao_senders.request_token("@my-channel", "01012345678")
398
+
399
+ # 2단계 — 사람이 받은 인증번호로 발신프로필 생성
400
+ created = client.kakao_senders.create(
401
+ token: "123456",
402
+ yellow_id: "@my-channel",
403
+ phone_number: "01012345678",
404
+ category_code: "001001" # categories 로 조회
405
+ )
406
+
407
+ kakao_sender_key = created.dig("data", "sender", "kakaoSenderKey")
408
+
409
+ client.kakao_senders.categories
410
+ client.kakao_senders.list
411
+ client.kakao_senders.sync # 전체 상태 동기화 (하루 한 번 권장)
412
+ client.kakao_senders.sync(kakao_sender_key) # 단건
413
+ ```
414
+
415
+ 채널이 카카오 쪽에서 차단되면 발송이 조용히 실패하기 시작합니다. `sync` 를
416
+ 주기적으로 돌리고 `block: true` 인 채널을 감시하세요.
417
+
418
+ ### 알림톡 템플릿 등록과 검수
419
+
420
+ ```ruby
421
+ created = client.notice_templates.create(
422
+ kakao_sender_key: kakao_sender_key,
423
+ template_name: "주문 접수 안내",
424
+ template_content: "\#{name}님, 주문 \#{orderNo}이 접수되었습니다.",
425
+ template_message_type: "BA", # BA 기본형 / EX 부가정보형 / AD 채널추가형 / MI 복합형
426
+ template_emphasize_type: "NONE", # NONE / TEXT / ITEM_LIST / IMAGE
427
+ category_code: "001001",
428
+
429
+ # sendgo 자체 정책 게이트 — 카카오 심사와 별개다
430
+ message_purpose: "order_delivery",
431
+ legal_basis: "transaction",
432
+ benefit_origin: "none",
433
+ expiry_type: "none",
434
+
435
+ # 선택 필드는 그냥 이어서 쓰면 된다 (snake_case → camelCase 자동 변환)
436
+ buttons: [{ name: "주문 조회", linkType: "WL", linkMo: "https://example.com/orders" }]
437
+ )
438
+
439
+ template_code = created.dig("data", "template", "templateCode")
440
+
441
+ # 검수 요청 — 증빙이 필요하면 파일도 붙인다 (첨부가 있으면 comment 필수)
442
+ client.notice_templates.request_inspection(template_code)
443
+
444
+ File.open("proof.png", "rb") do |f|
445
+ client.notice_templates.request_inspection(
446
+ template_code,
447
+ comment: "주문 확인 화면 첨부",
448
+ attachments: [["proof.png", f, "image/png"]]
449
+ )
450
+ end
451
+
452
+ # 결과는 비동기다. 웹훅이 없으므로 폴링한다
453
+ synced = client.notice_templates.sync(template_code)
454
+ synced.dig("data", "template", "inspectionStatus") # REG → REQ → APR / REJ
455
+ ```
456
+
457
+ `opt_in_review_confirmed` · `cta_clear_confirmed` · `policy_confirmed` 는 기본값이
458
+ `true` 지만, **내용을 실제로 검토한 뒤에** 그대로 두어야 합니다 — 이 값은 법적
459
+ 확인의 기록입니다.
460
+
461
+ 정책 필드 조합이 본문과 어긋나면 저장 단계에서 `POLICY_VALIDATION_FAILED` 로
462
+ 막힙니다. 예외의 `errors["reasons"]` 에 사유가 한국어로 담기니 그대로 사용자에게
463
+ 보여 주면 됩니다. 여기서 걸리는 문안은 **카카오 심사에서도 거의 반려**되므로,
464
+ 며칠 기다렸다 반려당하는 것보다 즉시 아는 편이 낫습니다.
465
+
466
+ ```ruby
467
+ client.notice_templates.list(kakao_sender_key: kakao_sender_key, inspection_status: "APR")
468
+ client.notice_templates.show(template_code)
469
+ client.notice_templates.update(template_code, template_content: "...") # 본문이 바뀌면 재검수 필요
470
+ client.notice_templates.cancel_inspection(template_code)
471
+ client.notice_templates.cancel_approval(template_code)
472
+ client.notice_templates.release(template_code) # 휴면 해제
473
+ client.notice_templates.delete(template_code) # sendgo 목록에서만 삭제된다
474
+ client.notice_templates.categories
475
+ ```
476
+
477
+ 이미지 템플릿은 `image:` 로 파일을 넘기면 multipart 로 나갑니다.
478
+
479
+ ```ruby
480
+ File.open("banner.jpg", "rb") do |f|
481
+ client.notice_templates.create(
482
+ kakao_sender_key: kakao_sender_key,
483
+ template_name: "이벤트 안내",
484
+ template_emphasize_type: "IMAGE",
485
+ image: ["banner.jpg", f, "image/jpeg"],
486
+ # ... 나머지 필드 동일
487
+ )
488
+ end
489
+ ```
490
+
491
+ > **삭제 동작이 채널마다 다릅니다.** 알림톡 템플릿은 카카오에 삭제 API 가 없어
492
+ > sendgo 목록에서만 빠지고 동기화하면 되살아납니다. 브랜드메시지 템플릿은
493
+ > 카카오 쪽에서도 실제로 삭제됩니다.
494
+
495
+ ### 브랜드메시지 템플릿
496
+
497
+ ```ruby
498
+ created = client.brand_templates.create(
499
+ kakao_sender_key: kakao_sender_key,
500
+ template_name: "여름 세일 안내",
501
+ template_type: "FI", # FT/FI/FW/FL/FC/FM/FP/FA — 서버가 chatBubbleType 으로 변환
502
+ template_content: "여름 세일이 시작되었습니다.",
503
+ image_url: "https://mud-kage.kakao.com/....jpg"
504
+ )
505
+
506
+ # 동보 발송(targeting="F")에는 변수가 없는 템플릿만 쓸 수 있다
507
+ created.dig("data", "template", "containsVariables")
508
+
509
+ client.brand_templates.list(kakao_sender_key: kakao_sender_key)
510
+ client.brand_templates.sync(template_code)
511
+ client.brand_templates.import(kakao_sender_key) # 카카오에 있는 템플릿 가져오기
512
+ client.brand_templates.delete(template_code) # 카카오에서도 삭제된다
513
+ ```
514
+
515
+ ### 발신번호 등록 신청
516
+
517
+ ```ruby
518
+ # 계정 종류에 맞는 유형과 유형별 필수 서류
519
+ client.sender_registration.number_types
520
+
521
+ # 형식·중복 미리 확인
522
+ check = client.sender_registration.validate("02-1234-5678", "team_main")
523
+
524
+ File.open("csu.pdf", "rb") do |f|
525
+ created = client.sender_registration.create(
526
+ sender_alias: "고객센터 대표번호",
527
+ sender_number_type: "team_main", # personal_other / team_main / team_other_company
528
+ phone_e164: "02-1234-5678",
529
+ files: { csuCertificate: ["csu.pdf", f, "application/pdf"] }
530
+ # check.dig("data", "duplicationReasonRequired") 가 true 면 필수
531
+ # duplication_reason: "부서별 분리 운영"
532
+ )
533
+
534
+ created.dig("data", "sender", "status") # PENDING — 운영자 승인 후 SUCCESS
535
+ end
536
+
537
+ client.sender_registration.list
538
+ client.sender_registration.update(sender_key, sender_alias: "새 이름")
539
+ client.sender_registration.delete(sender_key)
540
+ ```
541
+
542
+ **휴대폰 유형도 API 로 접수할 수 있습니다.** 콘솔의 PASS 본인인증 대신
543
+ 신분증 사본(`identityDocument`)을 첨부하면 sendgo 운영자가 직접 확인합니다.
544
+ 이 경로로 접수된 건은 응답의 `identityVerificationMethod` 가 `document` 이고
545
+ **자동 승인되지 않습니다** — 운영자 확인 전까지 `PENDING` 입니다.
546
+
547
+ 유형별 필수 서류는 `numberTypes()` 응답의 `requiredDocuments` 로 확인하세요.
548
+ 반려되면 `rejectionReason` 에 사유가 담깁니다.
549
+
550
+ ### 문자 템플릿
551
+
552
+ ```ruby
553
+ client.message_templates.create(
554
+ message_tran_type: "LMS",
555
+ message_tran_subject: "주문 안내", # LMS·MMS 는 필수
556
+ message_tran_msg: "주문이 접수되었습니다."
557
+ )
558
+
559
+ client.message_templates.list(message_type: "LMS")
560
+ client.message_templates.update(template_key, message_tran_msg: "...")
561
+ client.message_templates.delete(template_key)
562
+ ```
563
+
564
+ ### 이벤트 웹훅 — 심사 결과를 밀어 받기
565
+
566
+ ```ruby
567
+ created = client.webhook.subscribe("https://reseller.example.com/hooks/sendgo")
568
+
569
+ # 시크릿은 이 응답에서 한 번만 나온다. 즉시 저장한다.
570
+ secret = created.dig("data", "secret")
571
+
572
+ client.webhook.show # 구독 설정 + 마지막 전송 결과
573
+ client.webhook.test # 배선 확인
574
+ client.webhook.unsubscribe
575
+ ```
576
+
577
+ 받는 쪽에서는 **원본 바이트**로 서명을 검증합니다.
578
+
579
+ ```ruby
580
+ # Rails 라면 request.raw_post 가 원본이다. params 를 다시 인코딩하면 안 된다.
581
+ raw = request.raw_post
582
+
583
+ unless Sendgo::WebhookService.verify_signature(raw, request.headers["X-Sendgo-Signature"], secret)
584
+ head :unauthorized and return
585
+ end
586
+
587
+ payload = JSON.parse(raw)
588
+ # payload["event"] — sender.status_changed / notice_template.inspection_status_changed / ...
589
+ ```
590
+
591
+ 이벤트 목록은 `Sendgo::WebhookService::EVENTS` 로 확인할 수 있습니다.
592
+
593
+ ### 카카오 이미지 업로드
594
+
595
+ 브랜드메시지 템플릿의 `imageUrl` 은 **카카오가 호스팅하는 URL** 이어야 합니다.
596
+
597
+ ```ruby
598
+ uploaded = File.open("banner.jpg", "rb") do |f|
599
+ client.kakao_images.upload("default", ["banner.jpg", f, "image/jpeg"])
600
+ end
601
+
602
+ client.brand_templates.create(
603
+ kakao_sender_key: kakao_sender_key,
604
+ template_name: "여름 세일 안내",
605
+ template_type: "FI",
606
+ image_url: uploaded.dig("data", "imageUrl")
607
+ )
608
+
609
+ client.kakao_images.upload_many("carousel_feed", slides)
610
+ client.kakao_images.types # 유형별 필드·최대 개수
611
+ ```
612
+
613
+ ### 수신거부(080) 동기화
614
+
615
+ ```ruby
616
+ # 증분만 가져간다. 하루 한 번이면 충분하다.
617
+ client.rejected_numbers.list(since: "2026-09-01", count: 500)
618
+ ```
619
+
620
+ ---
621
+
363
622
  ## 변경 사항
364
623
 
624
+ ### 1.3.0 (2026-09-11)
625
+
626
+ - **관리 API 추가** — 콘솔에서만 되던 등록·심사를 코드로 처리합니다.
627
+ `client.kakao_senders`(채널 인증·등록·동기화, 브랜드메시지 M/N 신청),
628
+ `client.notice_templates`(알림톡 템플릿 CRUD·검수 요청·승인 취소·휴면 해제),
629
+ `client.brand_templates`(브랜드메시지 템플릿 CRUD·동기화·가져오기),
630
+ `client.sender_registration`(발신번호 등록 신청·중복 확인·유형 안내),
631
+ `client.message_templates`(문자 상용구 템플릿 CRUD).
632
+ - `HttpClient` 에 `put`·`patch`·`post_multipart` 를 추가했습니다.
633
+ 서류 첨부와 이미지 템플릿은 JSON 으로 보낼 수 없습니다.
634
+ - **`request` 가 PUT/PATCH 를 POST 로 보내던 문제를 함께 고쳤습니다.**
635
+ `case method` 의 `else` 분기가 전부 `Net::HTTP::Post` 를 만들고 있어,
636
+ 새 메서드를 그냥 얹었다면 405 만 받았을 자리입니다.
637
+ - **휴대폰 발신번호도 API 로 접수됩니다.** 콘솔의 PASS 본인인증 대신
638
+ `identityDocument`(신분증 사본)를 첨부하면 sendgo 운영자가 확인합니다.
639
+ 이 경로는 자동 승인되지 않고 항상 `PENDING` 으로 시작합니다.
640
+ - **이벤트 웹훅** 추가 — 발신번호 승인, 알림톡 검수 결과, 채널 차단,
641
+ 브랜드메시지 타겟팅 결과를 구독해 받습니다. 서명은 받은 원본 바이트로
642
+ 검증합니다(SDK 에 검증 헬퍼 포함).
643
+ - **카카오 이미지 업로드** 추가 — 브랜드메시지 템플릿의 `imageUrl` 은 카카오가
644
+ 호스팅하는 URL 이어야 하는데, 그 URL 을 얻는 길이 콘솔에만 있었습니다.
645
+ - **수신거부(080) 조회** 추가 — 자기 DB 의 수신 상태를 맞출 수 있습니다.
646
+
365
647
  ### 1.2.1 (2026-08-14)
366
648
 
367
649
  - 레지스트리 목록에 노출되는 패키지 설명에서 친구톡을 브랜드메시지로 교체했습니다.
data/lib/sendgo/client.rb CHANGED
@@ -14,6 +14,14 @@ module Sendgo
14
14
  # friendtalk: @deprecated 2025-12-31 종료. brand_message 를 사용하세요.
15
15
  attr_reader :alimtalk, :friendtalk, :brand_message, :short_url, :sms
16
16
 
17
+ # 관리 API (v2 전용) — 콘솔에서만 되던 등록·심사.
18
+ # 발송과 달리 대부분 즉시 완료되지 않는다 — 등록 성공은 "접수됨"이지
19
+ # "사용 가능"이 아니다. 카카오 채널 등록의 인증번호와 휴대폰 발신번호의
20
+ # 본인인증은 사람이 개입해야 하므로 API 로 대체되지 않는다.
21
+ attr_reader :kakao_senders, :notice_templates, :brand_templates,
22
+ :sender_registration, :message_templates,
23
+ :kakao_images, :rejected_numbers, :webhook
24
+
17
25
  def initialize(access_key:, secret_key:, kakao_sender_key: nil, sms_sender_key: nil,
18
26
  api_version: "v1", base_url: "https://sendgo.io")
19
27
  raise ArgumentError, "access_key와 secret_key는 필수입니다" if access_key.nil? || secret_key.nil?
@@ -28,6 +36,15 @@ module Sendgo
28
36
  # 짧은 URL — 링크 단축과 클릭 반응 분석. v2 전용.
29
37
  @short_url = ShortUrlService.new(http: http)
30
38
  @sms = SmsService.new(http: http, sms_sender_key: sms_sender_key)
39
+
40
+ @kakao_senders = KakaoSenderService.new(http: http)
41
+ @notice_templates = NoticeTemplateService.new(http: http)
42
+ @brand_templates = BrandTemplateService.new(http: http)
43
+ @sender_registration = SenderRegistrationService.new(http: http)
44
+ @message_templates = MessageTemplateService.new(http: http)
45
+ @kakao_images = KakaoImageService.new(http: http)
46
+ @rejected_numbers = RejectedNumberService.new(http: http)
47
+ @webhook = WebhookService.new(http: http)
31
48
  end
32
49
  end
33
50
  end
@@ -1,6 +1,7 @@
1
1
  require "net/http"
2
2
  require "json"
3
3
  require "base64"
4
+ require "securerandom"
4
5
 
5
6
  module Sendgo
6
7
  # Sendgo API HTTP 클라이언트.
@@ -21,11 +22,31 @@ module Sendgo
21
22
  request(:get, path, params: params, is_retry: false)
22
23
  end
23
24
 
25
+ def put(path, body)
26
+ request(:put, path, body: body, is_retry: false)
27
+ end
28
+
29
+ def patch(path, body)
30
+ request(:patch, path, body: body, is_retry: false)
31
+ end
32
+
24
33
  # DELETE 요청. 짧은 URL 리다이렉트 중지에서 사용한다.
25
34
  def delete(path)
26
35
  request(:delete, path, is_retry: false)
27
36
  end
28
37
 
38
+ # multipart/form-data POST — 서류·이미지 첨부가 있는 관리 API 전용.
39
+ #
40
+ # 발신번호 등록과 이미지 템플릿은 JSON 으로 보낼 수 없다. multipart 에는
41
+ # 배열도 불리언도 없으므로, Array/Hash 값은 JSON 문자열로 눌러 보낸다 —
42
+ # 서버가 그렇게 받아 읽는다.
43
+ #
44
+ # files 는 { 필드명 => [파일명, IO 또는 문자열, content_type] } 형태다.
45
+ # 같은 필드에 여러 파일을 붙이려면 배열로 넘긴다.
46
+ def post_multipart(path, fields = {}, files = {})
47
+ multipart_request(path, fields, files, is_retry: false)
48
+ end
49
+
29
50
  private
30
51
 
31
52
  def request(method, path, body: nil, params: nil, is_retry: false)
@@ -46,7 +67,16 @@ module Sendgo
46
67
  # 바디 없는 DELETE. Post 분기로 흘러가면 조용히 POST 로 나간다.
47
68
  Net::HTTP::Delete.new(uri)
48
69
  else
49
- Net::HTTP::Post.new(uri).tap do |r|
70
+ # PUT/PATCH 도 여기서 잡는다. 클래스를 고르지 않고 else 로 흘리면
71
+ # 전부 POST 로 나가서, 서버는 라우트를 못 찾고 405 를 준다.
72
+ klass =
73
+ case method
74
+ when :put then Net::HTTP::Put
75
+ when :patch then Net::HTTP::Patch
76
+ else Net::HTTP::Post
77
+ end
78
+
79
+ klass.new(uri).tap do |r|
50
80
  r["Content-Type"] = "application/json"
51
81
  r.body = body.to_json
52
82
  end
@@ -71,6 +101,123 @@ module Sendgo
71
101
  resp_body
72
102
  end
73
103
 
104
+ # 파일 내용을 미리 읽어 둔다. IO 는 한 번 소진되면 되감을 수 없어,
105
+ # 그러지 않으면 토큰 갱신 후 재시도가 빈 파일을 올린다.
106
+ def multipart_request(path, fields, files, is_retry:)
107
+ parts = []
108
+
109
+ fields.each do |name, value|
110
+ next if value.nil?
111
+
112
+ encoded =
113
+ case value
114
+ when true then "1"
115
+ when false then "0"
116
+ when Array, Hash then JSON.generate(value)
117
+ else value.to_s
118
+ end
119
+
120
+ parts << [:field, name.to_s, encoded]
121
+ end
122
+
123
+ files.each do |name, file|
124
+ next if file.nil?
125
+
126
+ # 파일 하나는 IO 이거나 [filename, io, content_type] 튜플이고,
127
+ # 여러 개는 그 튜플들의 배열이다. 이 둘을 구분하지 않으면 튜플
128
+ # 하나를 원소 세 개로 잘못 훑는다.
129
+ entries = multiple_files?(file) ? file : [file]
130
+
131
+ entries.each_with_index do |entry, index|
132
+ # 여러 개면 서버가 attachments[0] 형태를 기대한다.
133
+ field_name = entries.length > 1 || multiple_files?(file) ? "#{name}[#{index}]" : name.to_s
134
+ filename, io, content_type = normalize_file(entry)
135
+ parts << [:file, field_name, filename, read_all(io), content_type]
136
+ end
137
+ end
138
+
139
+ send_multipart(path, parts, is_retry: is_retry)
140
+ end
141
+
142
+ # 파일 목록인지 단일 파일 튜플인지 판별한다.
143
+ def multiple_files?(file)
144
+ file.is_a?(Array) && (file.first.is_a?(Array) || file.first.respond_to?(:read))
145
+ end
146
+
147
+ def normalize_file(entry)
148
+ case entry
149
+ when Array
150
+ filename, io, content_type = entry
151
+ [filename, io, content_type || "application/octet-stream"]
152
+ else
153
+ filename = entry.respond_to?(:path) ? File.basename(entry.path) : "upload"
154
+ [filename, entry, "application/octet-stream"]
155
+ end
156
+ end
157
+
158
+ def read_all(io)
159
+ io.respond_to?(:read) ? io.read : io.to_s
160
+ end
161
+
162
+ def send_multipart(path, parts, is_retry:)
163
+ token = @token_manager.get_token
164
+ boundary = "----SendgoBoundary#{SecureRandom.hex(12)}"
165
+ body = build_multipart_body(parts, boundary)
166
+ uri = URI("#{@base_url}/api/#{@api_version}/#{path}")
167
+
168
+ req = Net::HTTP::Post.new(uri)
169
+ req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
170
+ req["Accept"] = "application/json"
171
+ req["Authorization"] = bearer_auth(token)
172
+ req.body = body
173
+
174
+ # 파일 업로드는 JSON 요청보다 오래 걸린다.
175
+ resp = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
176
+ read_timeout: 60, open_timeout: 10) { |h| h.request(req) }
177
+
178
+ resp_body = JSON.parse(resp.body) rescue {}
179
+
180
+ unless resp.is_a?(Net::HTTPSuccess)
181
+ error_code = resp_body["code"]
182
+ endpoint = path.split("/").last
183
+ if !is_retry && @token_manager.should_refresh?(resp.code.to_i, error_code)
184
+ @token_manager.invalidate
185
+ return send_multipart(path, parts, is_retry: true)
186
+ end
187
+ raise SendgoError.from_response(resp.code.to_i, resp_body, endpoint, @api_version)
188
+ end
189
+
190
+ resp_body
191
+ end
192
+
193
+ # 버퍼를 처음부터 BINARY 로 만들고 붙이는 조각마다 `.b` 를 건다.
194
+ #
195
+ # UTF-8 버퍼에 파일 바이트(ASCII-8BIT)를 붙이면, 앞서 넣은 필드 값에
196
+ # 한글이 하나라도 있는 순간 Encoding::CompatibilityError 로 죽는다.
197
+ # 발신번호 등록은 별칭이 한글이고 서류는 PDF 라 항상 그 조합이다.
198
+ def build_multipart_body(parts, boundary)
199
+ body = +"".b
200
+
201
+ parts.each do |part|
202
+ body << "--#{boundary}\r\n".b
203
+
204
+ if part[0] == :field
205
+ _, name, value = part
206
+ body << %(Content-Disposition: form-data; name="#{name}"\r\n\r\n).b
207
+ body << "#{value}\r\n".b
208
+ else
209
+ _, name, filename, content, content_type = part
210
+ body << %(Content-Disposition: form-data; name="#{name}"; filename="#{filename}"\r\n).b
211
+ body << "Content-Type: #{content_type}\r\n\r\n".b
212
+ body << content.to_s.b
213
+ body << "\r\n".b
214
+ end
215
+ end
216
+
217
+ body << "--#{boundary}--\r\n".b
218
+ body
219
+ end
220
+
74
221
  def bearer_auth(token)
75
222
  return "Bearer #{token}" if @api_version == "v2"
76
223
  "Bearer #{Base64.strict_encode64(token)}"
@@ -0,0 +1,596 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "openssl"
5
+
6
+ module Sendgo
7
+ # 카카오 발신프로필(채널) 관리 — 등록 · 동기화 · 브랜드메시지 타겟팅 신청.
8
+ #
9
+ # v2 전용이며 **기업(Team) 소유 애플리케이션**만 사용할 수 있다.
10
+ #
11
+ # 채널 등록은 두 단계다. 카카오가 인증번호를 채널 관리자 **휴대폰으로 SMS
12
+ # 발송**하므로 완전 무인 자동화는 불가능하다 — 사람이 문자를 받아
13
+ # +create+ 에 넣어야 한다.
14
+ #
15
+ # @example
16
+ # # 1단계 — 관리자 휴대폰으로 인증번호 발송 (응답에 번호는 없다)
17
+ # client.kakao_senders.request_token("@my-channel", "01012345678")
18
+ #
19
+ # # 2단계 — 사람이 받은 인증번호로 발신프로필 생성
20
+ # created = client.kakao_senders.create(
21
+ # token: "123456",
22
+ # yellow_id: "@my-channel",
23
+ # phone_number: "01012345678",
24
+ # category_code: "001001"
25
+ # )
26
+ #
27
+ # kakao_sender_key = created.dig("data", "sender", "kakaoSenderKey")
28
+ class KakaoSenderService
29
+ RESOURCE = "kakao-senders"
30
+
31
+ def initialize(http:)
32
+ @http = http
33
+ end
34
+
35
+ # 1단계 — 채널 인증번호 발송.
36
+ #
37
+ # 응답에 인증번호는 들어있지 않다. 카카오가 +phone_number+ 로 SMS 를 보낸다.
38
+ def request_token(yellow_id, phone_number)
39
+ @http.post("#{RESOURCE}/token", { yellowId: yellow_id, phoneNumber: phone_number })
40
+ end
41
+
42
+ # 2단계 — 발신프로필 등록.
43
+ #
44
+ # 이미 등록된 채널을 다시 등록해도 오류가 아니다. 카카오가 같은 senderKey 를
45
+ # 돌려주고 서버가 기존 행을 갱신한다.
46
+ def create(token:, yellow_id:, phone_number:, category_code:)
47
+ @http.post(RESOURCE, {
48
+ token: token,
49
+ yellowId: yellow_id,
50
+ phoneNumber: phone_number,
51
+ categoryCode: category_code
52
+ })
53
+ end
54
+
55
+ # 목록 조회.
56
+ def list
57
+ @http.get(RESOURCE)
58
+ end
59
+
60
+ # 상세 조회.
61
+ def show(kakao_sender_key)
62
+ @http.get("#{RESOURCE}/#{CGI.escape(kakao_sender_key)}")
63
+ end
64
+
65
+ # 카테고리 조회. 등록 시 +category_code+ 로 넣을 값이다.
66
+ def categories(category_code = nil)
67
+ @http.get("#{RESOURCE}/categories", { categoryCode: category_code })
68
+ end
69
+
70
+ # 상태 동기화. 키를 주면 단건, 없으면 팀 전체.
71
+ #
72
+ # 채널이 카카오 쪽에서 차단·휴면되면 발송이 조용히 실패하기 시작한다.
73
+ # 그 사실을 먼저 알 방법은 이 호출뿐이므로 하루 한 번 정도 돌리는 게 좋다.
74
+ def sync(kakao_sender_key = nil)
75
+ path = kakao_sender_key ? "#{RESOURCE}/#{CGI.escape(kakao_sender_key)}/sync" : "#{RESOURCE}/sync"
76
+ @http.post(path, {})
77
+ end
78
+
79
+ # 브랜드메시지 M 신청에 필요한 광고성 정보 수신동의 증적자료 업로드.
80
+ # jpg/png, 5MB 이하.
81
+ #
82
+ # @param evidence [Array, IO] +["proof.png", io, "image/png"]+ 또는 열린 파일
83
+ def upload_brand_message_evidence(kakao_sender_key, evidence)
84
+ @http.post_multipart(
85
+ "#{RESOURCE}/#{CGI.escape(kakao_sender_key)}/brand-message/evidence",
86
+ {},
87
+ { evidence: evidence }
88
+ )
89
+ end
90
+
91
+ # 브랜드메시지 +M+(마케팅) / +N+(정보성) 사용 신청.
92
+ #
93
+ # 결과는 즉시 확정되지 않는다. 발신프로필의 +brandMessageStatus+ 로 확인한다.
94
+ def apply_brand_message_targeting(kakao_sender_key, target_type)
95
+ @http.post(
96
+ "#{RESOURCE}/#{CGI.escape(kakao_sender_key)}/brand-message/apply",
97
+ { targetType: target_type }
98
+ )
99
+ end
100
+ end
101
+
102
+ # 알림톡 템플릿 관리 — 등록 · 수정 · 검수 요청.
103
+ #
104
+ # v2 전용이며 **기업(Team) 소유 애플리케이션**만 사용할 수 있다.
105
+ #
106
+ # 템플릿은 만든 즉시 쓸 수 없다. 카카오 검수를 통과해야 한다.
107
+ #
108
+ # 등록 inspectionStatus=REG ← 발송 불가
109
+ # 검수 요청 inspectionStatus=REQ ← 카카오 심사 중
110
+ # 승인 inspectionStatus=APR ← 여기부터 발송 가능
111
+ # 반려 inspectionStatus=REJ ← comments 에 사유
112
+ #
113
+ # 검수 결과는 비동기다. 웹훅이 없으므로 +sync+ 로 폴링한다.
114
+ class NoticeTemplateService
115
+ RESOURCE = "notice-templates"
116
+
117
+ def initialize(http:)
118
+ @http = http
119
+ end
120
+
121
+ # 목록 조회.
122
+ def list(kakao_sender_key: nil, inspection_status: nil, search: nil, count: nil)
123
+ @http.get(RESOURCE, {
124
+ kakaoSenderKey: kakao_sender_key,
125
+ inspectionStatus: inspection_status,
126
+ search: search,
127
+ count: count
128
+ })
129
+ end
130
+
131
+ # 상세 조회. 응답의 +data.template.policy+ 에 정책 검토 상태가 들어 있다.
132
+ def show(template_code)
133
+ @http.get(path(template_code))
134
+ end
135
+
136
+ # 템플릿 등록.
137
+ #
138
+ # 정책 인자 일곱 개는 sendgo 자체 게이트다. 카카오 심사와 별개이며 조합이
139
+ # 본문과 어긋나면 +POLICY_VALIDATION_FAILED+ 로 거절된다. 확인 플래그 셋은
140
+ # 기본값이 +true+ 지만, **내용을 실제로 검토한 뒤에** 그대로 두어야 한다 —
141
+ # 이 값은 법적 확인의 기록이다.
142
+ #
143
+ # 선택 필드(+templateTitle+, +buttons+ 등)는 +extra+ 로 넘긴다.
144
+ # +image+ 를 주면 이미지 템플릿으로 multipart 전송한다.
145
+ def create(kakao_sender_key:, template_name:, template_content:,
146
+ template_message_type:, template_emphasize_type:, category_code:,
147
+ message_purpose:, legal_basis:, benefit_origin:, expiry_type:,
148
+ opt_in_review_confirmed: true, cta_clear_confirmed: true,
149
+ policy_confirmed: true, image: nil, **extra)
150
+ body = {
151
+ kakaoSenderKey: kakao_sender_key,
152
+ templateName: template_name,
153
+ templateContent: template_content,
154
+ templateMessageType: template_message_type,
155
+ templateEmphasizeType: template_emphasize_type,
156
+ categoryCode: category_code,
157
+ messagePurpose: message_purpose,
158
+ legalBasis: legal_basis,
159
+ benefitOrigin: benefit_origin,
160
+ expiryType: expiry_type,
161
+ optInReviewConfirmed: opt_in_review_confirmed,
162
+ ctaClearConfirmed: cta_clear_confirmed,
163
+ policyConfirmed: policy_confirmed
164
+ }.merge(camelize_keys(extra))
165
+
166
+ return @http.post_multipart(RESOURCE, body, { image: image }) if image
167
+
168
+ @http.post(RESOURCE, body)
169
+ end
170
+
171
+ # 템플릿 수정.
172
+ #
173
+ # 발신프로필과 템플릿 코드는 바꿀 수 없다. 본문·버튼처럼 카카오에 등록된
174
+ # 내용이 바뀌면 검수 상태가 되돌아가므로 재검수를 요청해야 한다.
175
+ def update(template_code, **fields)
176
+ @http.put(path(template_code), camelize_keys(fields))
177
+ end
178
+
179
+ # 템플릿 삭제.
180
+ #
181
+ # **카카오는 템플릿 삭제 API 를 제공하지 않는다.** sendgo 목록에서만
182
+ # 지워지고 비즈니스 채널 쪽 템플릿은 남는다. 동기화하면 다시 나타난다.
183
+ def delete(template_code)
184
+ @http.delete(path(template_code))
185
+ end
186
+
187
+ # 카카오에서 검수 상태와 반려 사유를 다시 읽어 온다.
188
+ def sync(template_code)
189
+ @http.post("#{path(template_code)}/sync", {})
190
+ end
191
+
192
+ # 검수 요청.
193
+ #
194
+ # 첨부가 있으면 +comment+ 는 필수다. 정책 검토를 통과하지 못한 템플릿은
195
+ # +POLICY_REVIEW_REQUIRED+ 로 거절되고 +errors.reasons+ 에 사유가 담긴다.
196
+ def request_inspection(template_code, comment: nil, attachments: nil)
197
+ endpoint = "#{path(template_code)}/inspection"
198
+
199
+ if attachments.nil? || attachments.empty?
200
+ body = comment ? { comment: comment } : {}
201
+ return @http.post(endpoint, body)
202
+ end
203
+
204
+ @http.post_multipart(endpoint, { comment: comment }, { attachments: attachments })
205
+ end
206
+
207
+ # 검수 요청 취소. 아직 심사 중(+REQ+)일 때만 통한다.
208
+ def cancel_inspection(template_code)
209
+ @http.delete("#{path(template_code)}/inspection")
210
+ end
211
+
212
+ # 승인 취소. 승인(+APR+)된 템플릿을 되돌린다. 이후에는 발송할 수 없다.
213
+ def cancel_approval(template_code)
214
+ @http.delete("#{path(template_code)}/approval")
215
+ end
216
+
217
+ # 휴면 해제. 오래 안 쓴 템플릿이 dormant 로 잠기면 이걸로 깨운다.
218
+ def release(template_code)
219
+ @http.post("#{path(template_code)}/release", {})
220
+ end
221
+
222
+ # 템플릿 카테고리 코드 조회.
223
+ def categories(category_code = nil)
224
+ @http.get("#{RESOURCE}/categories", { categoryCode: category_code })
225
+ end
226
+
227
+ private
228
+
229
+ def path(template_code)
230
+ "#{RESOURCE}/#{CGI.escape(template_code)}"
231
+ end
232
+
233
+ def camelize_keys(hash)
234
+ Sendgo.camelize_keys(hash)
235
+ end
236
+ end
237
+
238
+ # 브랜드메시지(구 친구톡) 템플릿 관리.
239
+ #
240
+ # v2 전용이며 **기업(Team) 소유 애플리케이션**만 사용할 수 있다.
241
+ # 알림톡 템플릿과 달리 **검수 요청 단계가 없다.**
242
+ #
243
+ # +template_type+ 은 친구톡 표기(FT/FI/FW/FL/FC/FM/FP/FA)를 그대로 쓴다 —
244
+ # 서버가 chatBubbleType 으로 변환한다.
245
+ class BrandTemplateService
246
+ RESOURCE = "brand-templates"
247
+
248
+ def initialize(http:)
249
+ @http = http
250
+ end
251
+
252
+ # 목록 조회.
253
+ def list(kakao_sender_key: nil, search: nil, count: nil)
254
+ @http.get(RESOURCE, { kakaoSenderKey: kakao_sender_key, search: search, count: count })
255
+ end
256
+
257
+ # 상세 조회. sendgo 코드(+KFT-...+)와 카카오 브랜드 템플릿 코드 둘 다 받는다.
258
+ def show(template_code)
259
+ @http.get(path(template_code))
260
+ end
261
+
262
+ # 템플릿 등록. 선택 필드는 +extra+ 로 넘긴다 (snake_case 자동 변환).
263
+ def create(kakao_sender_key:, template_name:, template_type:, **extra)
264
+ @http.post(RESOURCE, {
265
+ kakaoSenderKey: kakao_sender_key,
266
+ templateName: template_name,
267
+ templateType: template_type
268
+ }.merge(Sendgo.camelize_keys(extra)))
269
+ end
270
+
271
+ # 템플릿 수정. 발신프로필은 바꿀 수 없다.
272
+ def update(template_code, **fields)
273
+ @http.put(path(template_code), Sendgo.camelize_keys(fields))
274
+ end
275
+
276
+ # 템플릿 삭제. 알림톡과 달리 카카오 쪽에서도 실제로 삭제된다.
277
+ def delete(template_code)
278
+ @http.delete(path(template_code))
279
+ end
280
+
281
+ # 동기화. 카카오 쪽에서 이미 삭제됐으면 로컬에서도 제거하고
282
+ # +data.deleted: true+ 를 반환한다.
283
+ def sync(template_code)
284
+ @http.post("#{path(template_code)}/sync", {})
285
+ end
286
+
287
+ # 발신프로필 단위 가져오기 — 카카오 쪽에 이미 있는 템플릿을 들여온다.
288
+ def import(kakao_sender_key)
289
+ @http.post("#{RESOURCE}/import", { kakaoSenderKey: kakao_sender_key })
290
+ end
291
+
292
+ private
293
+
294
+ def path(template_code)
295
+ "#{RESOURCE}/#{CGI.escape(template_code)}"
296
+ end
297
+ end
298
+
299
+ # 발신번호(문자) 등록 · 심사 접수.
300
+ #
301
+ # v2 전용. 카카오와 달리 **개인 계정 애플리케이션도** 쓸 수 있다.
302
+ #
303
+ # 등록하면 곧바로 쓸 수 있는 게 아니라 +PENDING+ 으로 **접수**되고, 운영자
304
+ # 승인 후 +SUCCESS+ 가 된다.
305
+ class SenderRegistrationService
306
+ RESOURCE = "senders"
307
+
308
+ # API 로 접수할 수 있는 발신번호 유형 — 전부다.
309
+ REGISTRABLE_TYPES = %w[
310
+ personal_mobile
311
+ personal_other
312
+ team_main
313
+ team_representative_mobile
314
+ team_emp_mobile
315
+ team_other_company
316
+ ].freeze
317
+
318
+ # 신분증 사본(+identityDocument+)이 필요한 유형.
319
+ #
320
+ # 콘솔은 PASS 본인인증을 쓰지만 API 는 신분증 사본을 받아 sendgo 운영자가
321
+ # 직접 확인한다. 이 경로로 접수된 건은 자동 승인되지 않는다.
322
+ IDENTITY_DOCUMENT_TYPES = %w[personal_mobile team_representative_mobile team_emp_mobile].freeze
323
+
324
+ def initialize(http:)
325
+ @http = http
326
+ end
327
+
328
+ # 목록 조회. 심사 상태(+status+)를 여기서 확인한다.
329
+ def list
330
+ @http.get(RESOURCE)
331
+ end
332
+
333
+ # 상세 조회.
334
+ def show(sender_key)
335
+ @http.get("#{RESOURCE}/#{CGI.escape(sender_key)}")
336
+ end
337
+
338
+ # 계정 종류에 맞는 발신번호 유형과 유형별 필수 서류.
339
+ #
340
+ # 유형별 +identityVerification+(+none+/+document+)과 필요한 서류 목록을 준다.
341
+ def number_types
342
+ @http.get("#{RESOURCE}/number-types")
343
+ end
344
+
345
+ # 등록 전 형식·중복 확인.
346
+ #
347
+ # 응답의 +duplicationReasonRequired+ 가 true 면 +create+ 에
348
+ # +duplication_reason+ 을 함께 넣어야 한다.
349
+ def validate(phone_e164, sender_number_type)
350
+ @http.post("#{RESOURCE}/validate", { phoneE164: phone_e164, senderNumberType: sender_number_type })
351
+ end
352
+
353
+ # 등록 신청. 서류가 붙으므로 multipart 로 나간다.
354
+ #
355
+ # +files+ 에는 최소한 +csuCertificate+(통신서비스 이용증명원)가 있어야 한다.
356
+ # 휴대폰 계열은 +identityDocument+(신분증 사본)가, +team_other_company+ 는
357
+ # 수임·위임 서류가 더 필요하다 — {#number_types} 로 확인한다.
358
+ def create(sender_alias:, sender_number_type:, phone_e164:, files:, **extra)
359
+ fields = {
360
+ senderAlias: sender_alias,
361
+ senderNumberType: sender_number_type,
362
+ phoneE164: phone_e164
363
+ }.merge(Sendgo.camelize_keys(extra))
364
+
365
+ @http.post_multipart(RESOURCE, fields, files)
366
+ end
367
+
368
+ # 별칭 변경 / 기본 발신 지정. 번호와 심사 상태는 바꿀 수 없다.
369
+ def update(sender_key, sender_alias:, primary_type: nil)
370
+ body = { senderAlias: sender_alias }
371
+ body[:primaryType] = primary_type unless primary_type.nil?
372
+
373
+ @http.patch("#{RESOURCE}/#{CGI.escape(sender_key)}", body)
374
+ end
375
+
376
+ # 삭제. 기본 발신번호를 지우면 남은 번호 중 하나가 기본으로 승계된다.
377
+ def delete(sender_key)
378
+ @http.delete("#{RESOURCE}/#{CGI.escape(sender_key)}")
379
+ end
380
+ end
381
+
382
+ # 문자(SMS/LMS/MMS) 상용구 템플릿.
383
+ #
384
+ # v2 전용. 카카오 템플릿과 달리 **검수가 없어** 만들면 바로 쓸 수 있고,
385
+ # 기업 계정이 아니어도 된다.
386
+ class MessageTemplateService
387
+ RESOURCE = "message-templates"
388
+
389
+ def initialize(http:)
390
+ @http = http
391
+ end
392
+
393
+ # 목록 조회.
394
+ def list(message_type: nil, search: nil, count: nil)
395
+ @http.get(RESOURCE, { messageType: message_type, search: search, count: count })
396
+ end
397
+
398
+ # 상세 조회.
399
+ def show(template_key)
400
+ @http.get("#{RESOURCE}/#{CGI.escape(template_key)}")
401
+ end
402
+
403
+ # 등록. LMS·MMS 는 +message_tran_subject+ 가 필수다.
404
+ def create(message_tran_type:, message_tran_msg:, message_tran_subject: nil, is_favorite: false)
405
+ body = {
406
+ messageTranType: message_tran_type,
407
+ messageTranMsg: message_tran_msg,
408
+ isFavorite: is_favorite
409
+ }
410
+ body[:messageTranSubject] = message_tran_subject unless message_tran_subject.nil?
411
+
412
+ @http.post(RESOURCE, body)
413
+ end
414
+
415
+ # 수정.
416
+ def update(template_key, **fields)
417
+ @http.put("#{RESOURCE}/#{CGI.escape(template_key)}", Sendgo.camelize_keys(fields))
418
+ end
419
+
420
+ # 삭제 (소프트 삭제 — 목록에서만 사라진다).
421
+ def delete(template_key)
422
+ @http.delete("#{RESOURCE}/#{CGI.escape(template_key)}")
423
+ end
424
+ end
425
+
426
+ # Ruby 쪽 인자는 snake_case, Sendgo API 는 camelCase 다. 서비스마다 매핑을
427
+ # 손으로 적으면 필드가 늘 때마다 빠뜨리는 곳이 생기므로 한 곳에서 변환한다.
428
+ #
429
+ # +additional_content+ 는 서버가 그대로 받으므로 예외로 둔다.
430
+ KEEP_AS_IS_KEYS = %w[additional_content].freeze
431
+
432
+ def self.camelize_keys(hash)
433
+ hash.each_with_object({}) do |(key, value), result|
434
+ name = key.to_s
435
+ camel =
436
+ if KEEP_AS_IS_KEYS.include?(name) || !name.include?("_")
437
+ name
438
+ else
439
+ head, *rest = name.split("_")
440
+ head + rest.map(&:capitalize).join
441
+ end
442
+
443
+ result[camel.to_sym] = value
444
+ end
445
+ end
446
+ end
447
+
448
+ module Sendgo
449
+ # 이벤트 웹훅 구독 — 등록·심사 결과를 밀어 받는다. v2 전용.
450
+ #
451
+ # 심사는 비동기라 폴링 말고는 방법이 없었다. 구독해 두면 상태가 바뀔 때마다
452
+ # 도착한다.
453
+ #
454
+ # @example
455
+ # created = client.webhook.subscribe("https://reseller.example.com/hooks/sendgo")
456
+ #
457
+ # # 시크릿은 이 응답에서 한 번만 나온다. 즉시 저장한다.
458
+ # secret = created.dig("data", "secret")
459
+ class WebhookService
460
+ RESOURCE = "webhook"
461
+
462
+ # 구독할 수 있는 이벤트.
463
+ EVENTS = %w[
464
+ sender.status_changed
465
+ notice_template.inspection_status_changed
466
+ kakao_sender.status_changed
467
+ kakao_sender.brand_message_status_changed
468
+ ].freeze
469
+
470
+ def initialize(http:)
471
+ @http = http
472
+ end
473
+
474
+ # 현재 구독 설정. 마지막 전송 결과(+lastStatus+)도 함께 온다.
475
+ def show
476
+ @http.get(RESOURCE)
477
+ end
478
+
479
+ # 구독 생성·수정.
480
+ #
481
+ # +secret+ 을 생략하면 서버가 만들어 **이 응답에서 한 번만** 돌려준다.
482
+ # 이미 시크릿이 있는 상태에서 생략하면 기존 값을 유지한다 — URL 만 바꾸는
483
+ # 호출이 서명 키를 날리지 않는다.
484
+ #
485
+ # +events+ 가 nil 이면 전체 구독이다.
486
+ def subscribe(url, secret: nil, events: nil, enabled: true)
487
+ body = { url: url, enabled: enabled }
488
+ body[:secret] = secret unless secret.nil?
489
+ body[:events] = events unless events.nil?
490
+
491
+ @http.put(RESOURCE, body)
492
+ end
493
+
494
+ # 테스트 이벤트 발송. 구독 목록과 무관하게 도착한다.
495
+ def test
496
+ @http.post("#{RESOURCE}/test", {})
497
+ end
498
+
499
+ # 구독 해지.
500
+ def unsubscribe
501
+ @http.delete(RESOURCE)
502
+ end
503
+
504
+ # 수신한 웹훅의 서명을 검증한다.
505
+ #
506
+ # +raw_body+ 는 **받은 바이트 그대로**여야 한다. 파싱한 뒤 다시 인코딩한
507
+ # 값으로 계산하면 키 순서나 이스케이프 차이로 검증이 깨진다.
508
+ # Rails 라면 +request.raw_post+ 다.
509
+ def self.verify_signature(raw_body, signature, secret)
510
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
511
+
512
+ ActiveSupport::SecurityUtils.secure_compare(expected, signature.to_s)
513
+ rescue NameError
514
+ # ActiveSupport 가 없는 순수 Ruby 환경 폴백.
515
+ # 길이가 다르면 fixed_length_secure_compare 가 던지므로 먼저 막는다.
516
+ return false unless expected.bytesize == signature.to_s.bytesize
517
+
518
+ OpenSSL.fixed_length_secure_compare(expected, signature.to_s)
519
+ end
520
+ end
521
+
522
+ # 카카오 이미지 업로드 — 브랜드메시지 템플릿에 넣을 URL 발급.
523
+ #
524
+ # v2 전용, 기업 계정 전용. 브랜드메시지 템플릿의 +imageUrl+ 은 아무 URL 이나
525
+ # 되는 게 아니라 **카카오가 호스팅하는 URL** 이어야 하고, 그 URL 을 얻는
526
+ # 방법이 이 업로드뿐이다.
527
+ #
528
+ # @example
529
+ # uploaded = File.open("banner.jpg", "rb") do |f|
530
+ # client.kakao_images.upload("default", ["banner.jpg", f, "image/jpeg"])
531
+ # end
532
+ #
533
+ # client.brand_templates.create(
534
+ # kakao_sender_key: kakao_sender_key,
535
+ # template_name: "여름 세일 안내",
536
+ # template_type: "FI",
537
+ # image_url: uploaded.dig("data", "imageUrl")
538
+ # )
539
+ class KakaoImageService
540
+ RESOURCE = "kakao-images"
541
+
542
+ # 파일 하나를 올리고 URL 하나를 받는 유형.
543
+ SINGLE_TYPES = %w[alimtalk alimtalk_highlight default wide wide_item_list_first].freeze
544
+
545
+ # 파일 여러 개를 올리는 유형과 최대 개수.
546
+ MULTI_TYPES = {
547
+ "wide_item_list" => 4,
548
+ "carousel_feed" => 10,
549
+ "carousel_commerce" => 11
550
+ }.freeze
551
+
552
+ def initialize(http:)
553
+ @http = http
554
+ end
555
+
556
+ # 업로드 가능한 유형과 제약.
557
+ def types
558
+ @http.get("#{RESOURCE}/types")
559
+ end
560
+
561
+ # 단일 이미지 업로드. jpg/png, 2MB 이하. +data.imageUrl+ 을 받는다.
562
+ def upload(image_type, image)
563
+ @http.post_multipart(path(image_type), {}, { image: image })
564
+ end
565
+
566
+ # 다중 이미지 업로드. 유형별 최대 개수가 다르다.
567
+ def upload_many(image_type, images)
568
+ @http.post_multipart(path(image_type), {}, { images: images })
569
+ end
570
+
571
+ private
572
+
573
+ def path(image_type)
574
+ "#{RESOURCE}/#{CGI.escape(image_type)}"
575
+ end
576
+ end
577
+
578
+ # 수신거부(080) 번호 조회. v2 전용, 조회 전용.
579
+ #
580
+ # 발송 API 가 알아서 제외하지만 **자기 DB 의 수신 상태도 맞춰야** 한다 —
581
+ # 그러지 않으면 매번 보내고 매번 걸러지는 것을 반복하고, 자기 화면에서는
582
+ # 여전히 "수신 동의" 로 보인다.
583
+ class RejectedNumberService
584
+ RESOURCE = "rejected-numbers"
585
+
586
+ def initialize(http:)
587
+ @http = http
588
+ end
589
+
590
+ # 증분만 가져가려면 +since+ 를 쓴다. 전체를 매번 받으면 번호가 쌓일수록
591
+ # 무거워진다.
592
+ def list(since: nil, search: nil, count: nil)
593
+ @http.get(RESOURCE, { since: since, search: search, count: count })
594
+ end
595
+ end
596
+ end
@@ -1,3 +1,3 @@
1
1
  module Sendgo
2
- VERSION = "1.2.1"
2
+ VERSION = "1.3.0"
3
3
  end
data/lib/sendgo.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "sendgo/friendtalk"
7
7
  require_relative "sendgo/brand_message"
8
8
  require_relative "sendgo/short_url"
9
9
  require_relative "sendgo/sms"
10
+ require_relative "sendgo/management"
10
11
  require_relative "sendgo/client"
11
12
 
12
13
  # Sendgo Ruby SDK — 카카오 알림톡/친구톡, SMS/LMS/MMS
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sendgo
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sendgo
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-14 00:00:00.000000000 Z
11
+ date: 2026-09-11 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Sendgo API를 Ruby에서 간편하게 사용하기 위한 공식 SDK
14
14
  email:
@@ -25,6 +25,7 @@ files:
25
25
  - lib/sendgo/error.rb
26
26
  - lib/sendgo/friendtalk.rb
27
27
  - lib/sendgo/http_client.rb
28
+ - lib/sendgo/management.rb
28
29
  - lib/sendgo/short_url.rb
29
30
  - lib/sendgo/sms.rb
30
31
  - lib/sendgo/token_manager.rb