Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 93 additions & 16 deletions confluence-mdx/bin/reverse_sync/xhtml_patcher.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""XHTML Patcher — fragment 단위 DOM patch를 적용한다."""
from typing import List, Dict
from typing import List, Dict, Optional, Tuple
from bs4 import BeautifulSoup, NavigableString, Tag
import difflib
import re
Expand Down Expand Up @@ -628,6 +628,9 @@ def _apply_text_changes(element: Tag, old_text: str, new_text: str):
claim_end_set.add(left_idx)
exclude_start_set.add(right_idx)

prev_replaced = None # 직전 non-empty 노드의 교체 NavigableString
prev_eff_end = 0 # 직전 non-empty 노드의 effective_end (trailing_in_range 포함)

for i, (node_start, node_end, node) in enumerate(node_ranges):
if node_start == node_end:
continue
Expand Down Expand Up @@ -665,35 +668,53 @@ def _apply_text_changes(element: Tag, old_text: str, new_text: str):
exclude_insert_at_start=exclude_at_start,
)

# 직전 노드 범위와 현재 노드 범위 사이의 gap이 diff로 삭제/축소된 경우,
# leading whitespace를 제거한다.
# 예1: <strong>IDENTIFIER</strong> 조사 → IDENTIFIER조사 (공백 삭제)
# 예2: <p> Admin → <p>Admin (공백 축소: gap 2→1이면 leading 제거)
if leading and i > 0:
# 직전 노드 범위와 현재 노드 범위 사이의 gap이 diff로 변경된 경우,
# gap 변화량을 leading/trailing whitespace에 분배한다.
# - 축소: leading 줄이기 우선 → 부족하면 이전 노드 trailing 추가 흡수
# - 확장: 이전 노드 trailing 늘리기
# - 삭제: leading 제거 후 잔여분은 이전 노드 trailing 흡수
#
# trailing_in_range가 이전 노드에서 True였으면 trailing 부분은 이미
# diff로 처리된 상태이므로, 이미 처리된 영역을 제외한 나머지 gap만 조정한다.
if i > 0:
prev_end = node_ranges[i - 1][1]
if prev_end < node_start:
gap_old = old_stripped[prev_end:node_start]
# trailing_in_range로 이미 diff 처리된 영역을 제외
gap_start = max(prev_end, prev_eff_end)
if gap_start < node_start:
gap_old = old_stripped[gap_start:node_start]
gap_new = _map_text_range(
old_stripped, new_stripped, opcodes, prev_end, node_start
old_stripped, new_stripped, opcodes, gap_start, node_start
)
if not gap_new:
leading = ''
elif (gap_new.isspace() and gap_old.isspace()
and len(gap_new) < len(gap_old)
and len(gap_new) <= len(leading)):
gap_delta = _compute_gap_whitespace_delta(gap_old, gap_new)
if gap_delta is not None:
leading, prev_replaced = _redistribute_gap_whitespace(
leading, prev_replaced, gap_delta
)
elif not gap_new and leading:
leading = ''
elif leading and prev_eff_end > prev_end and prev_end < node_start:
shared_old = old_stripped[prev_end:prev_eff_end]
shared_new = _map_text_range(
old_stripped, new_stripped, opcodes, prev_end, prev_eff_end
)
shared_delta = _compute_gap_whitespace_delta(shared_old, shared_new)
if shared_delta and shared_delta > 0:
leading = leading[min(shared_delta, len(leading)):]

if trailing_in_range:
# diff가 trailing whitespace를 처리했으므로 별도 보존 불필요
node.replace_with(NavigableString(leading + new_node_text))
replacement = NavigableString(leading + new_node_text)
else:
# 마지막 노드의 edge trailing whitespace가 변경된 경우 반영
if trailing and i == last_nonempty_idx:
old_edge_trailing = old_text[len(old_text.rstrip()):]
new_edge_trailing = new_text[len(new_text.rstrip()):]
if old_edge_trailing != new_edge_trailing:
trailing = new_edge_trailing
node.replace_with(NavigableString(leading + new_node_text + trailing))
replacement = NavigableString(leading + new_node_text + trailing)
node.replace_with(replacement)
prev_replaced = replacement
prev_eff_end = effective_end


def _find_block_ancestor(node):
Expand All @@ -710,6 +731,62 @@ def _find_block_ancestor(node):
return None


def _compute_gap_whitespace_delta(gap_old: str, gap_new: str) -> Optional[int]:
"""gap 변경에서 공통 토큰을 제외한 공백 길이 변화량만 분리한다."""
if gap_old == gap_new:
return 0

if (not gap_old or gap_old.isspace()) and (not gap_new or gap_new.isspace()):
return len(gap_old) - len(gap_new)

prefix_len = 0
max_prefix = min(len(gap_old), len(gap_new))
while prefix_len < max_prefix and gap_old[prefix_len] == gap_new[prefix_len]:
prefix_len += 1

suffix_len = 0
max_suffix = min(len(gap_old) - prefix_len, len(gap_new) - prefix_len)
while suffix_len < max_suffix and gap_old[-(suffix_len + 1)] == gap_new[-(suffix_len + 1)]:
suffix_len += 1

old_end = len(gap_old) - suffix_len if suffix_len else len(gap_old)
new_end = len(gap_new) - suffix_len if suffix_len else len(gap_new)
old_mid = gap_old[prefix_len:old_end]
new_mid = gap_new[prefix_len:new_end]

if (old_mid and not old_mid.isspace()) or (new_mid and not new_mid.isspace()):
return None

return len(old_mid) - len(new_mid)


def _redistribute_gap_whitespace(
leading: str,
prev_replaced: Optional[NavigableString],
gap_delta: int,
) -> Tuple[str, Optional[NavigableString]]:
"""gap 공백 증감을 current leading / previous trailing로 재분배한다."""
if gap_delta > 0:
reduce = min(gap_delta, len(leading))
leading = leading[reduce:]
remaining = gap_delta - reduce
if remaining > 0 and prev_replaced is not None:
prev_text = str(prev_replaced)
prev_trailing = prev_text[len(prev_text.rstrip()):]
trim = min(remaining, len(prev_trailing))
if trim > 0:
replacement = NavigableString(prev_text[:len(prev_text) - trim])
prev_replaced.replace_with(replacement)
prev_replaced = replacement
elif gap_delta < 0 and prev_replaced is not None:
prev_text = str(prev_replaced)
replacement = NavigableString(prev_text + ' ' * abs(gap_delta))
prev_replaced.replace_with(replacement)
prev_replaced = replacement

return leading, prev_replaced


def _map_text_range(old_text: str, new_text: str, opcodes, start: int, end: int,
include_insert_at_end: bool = False,
exclude_insert_at_start: bool = False) -> str:
Expand Down
172 changes: 172 additions & 0 deletions confluence-mdx/tests/reverse-sync/883654669/improved.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
---
title: 'Slack DM 연동'
confluenceUrl: 'https://querypie.atlassian.net/wiki/spaces/QM/pages/883654669/Slack+DM'
---

import { Callout } from 'nextra/components'

# Slack DM 연동

### Overview

Slack App을 QueryPie에 연동하고, QueryPie로부터 Direct Message 알림을 수신할 수 있습니다.

<Callout type="info">
본 문서는 **10.2.6 또는 그 이상의 버전**에 적용됩니다.
10.2.5 또는 그 이하 버전의 Slack 연동 방법은 [10.1.0 버전 매뉴얼 문서](https://docs.querypie.com/ko/querypie-manual/10.1.0/workflow-configurations)를 참고해주세요.
</Callout>

#### 구성 시 사전에 필요한 권한

* Slack Workspace 관리자 권한 계정 (Slack Workspace에 App을 설치하기 위해 필요)
* QueryPie Owner 및 Approval Admin 권한 계정

### Slack API 에서 DM App 생성 (via Manifest Files)

App Manifest를 이용하여 QueryPie DM 연동 전용 Slack App을 생성합니다.


1. [https://api.slack.com/apps](https://api.slack.com/apps) 으로 이동하여 `Create an App` 을 클릭합니다.<br/> <br/>
<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/image-20231227-065658.png" alt="image-20231227-065658.png" width="760" />
</figure>
2. Create an app 모달에서, App 생성 방식을 선택합니다. `From a manifest` 를 클릭합니다.<br/> <br/>
<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/screenshot-20250218-131725.png" alt="screenshot-20250218-131725.png" width="712" />
</figure>
3. Pick a workspace 모달에서 QueryPie와 연동할 Slack Workspace를 선택한 뒤 다음 단계로 이동합니다.<br/> <br/>
<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/image-20231227-065951.png" alt="image-20231227-065951.png" width="710" />
</figure>
4. Create app from manifest 모달에서 JSON 형식의 App Manifest를 입력합니다. <br/>미리 채워져 있는 내용을 삭제하고 아래 App Manifest를 붙여넣은 뒤 다음 단계로 이동합니다.<br/>:light_bulb_on: `{{..}}` 안의 값은 원하는 값으로 변경해 주세요. <br/>
```
{
"display_information": {
"name": "{{name}}"
},
"features": {
"bot_user": {
"display_name": "{{display_name}}",
"always_online": false
}
},
"oauth_config": {
"scopes": {
"bot": [
"chat:write",
"users:read.email",
"users:read"
]
}
},
"settings": {
"interactivity": {
"is_enabled": true
},
"org_deploy_enabled": false,
"socket_mode_enabled": true,
"token_rotation_enabled": false
}
}
```
5. 설정 내용을 검토하고 `Create` 버튼을 클릭하여 App 생성을 완료합니다.<br/> <br/>
<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/image-20240115-220447.png" alt="image-20240115-220447.png" width="763" />
</figure>

### Slack Workspace에 Slack App 설치

1. Settings &gt; Install App에서 `Install to Workspace` 버튼을 클릭하여 생성된 앱을 Slack Workspace에 설치합니다.
2. 권한 요청 페이지에서, `허용`을 클릭합니다. <br/>
<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/image-20240115-221520.png" alt="image-20240115-221520.png" width="760" />
</figure>
3. 설정한 Slack Workspace에서 Slack DM 전용 App이 생성된 것을 확인할 수 있습니다.
<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/image-20240115-221618.png" alt="image-20240115-221618.png" width="760" />
</figure>

### Bot User OAuth Token 획득

Features &gt; OAuth & Permissions 메뉴 내 OAuth Tokens for Your Workspace 섹션에서, **Bot User OAuth Token** 을 복사하여 기록해 둡니다.

<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/image-20240418-022640.png" alt="image-20240418-022640.png" width="764" />
</figure>


### App-Level Token 생성

<Callout type="info">
App manifest로 Slack app을 생성할 때 Socket Mode와 관련 권한들을 활성화할 수는 있지만, App Level Token(xapp-)은 자동으로 생성되지 않습니다.
* App Level Token은 보안 자격 증명(security credential)이기 때문에 manifest로 자동 생성/노출되면 보안상 위험
* App manifest는 앱의 구성(configuration)만 정의하고, 실제 인증 토큰들은 별도로 생성/관리됨
</Callout>

다음 단계를 수행하여 App-Level Token을 수동으로 생성합니다.

1. 앱 설정 페이지의 Settings &gt; Basic Information 메뉴의 App-Level Tokens 섹션으로 이동하고, `Generate Token and Scope` 버튼을 클릭합니다.<br/> <br/>
<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/screenshot-20250218-140951.png" alt="screenshot-20250218-140951.png" width="736" />
</figure>
2. Generate an app-level token 모달에서, Add Scope 버튼을 클릭한 뒤 `connections:write` 를 추가합니다.<br/> <br/>
<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/screenshot-20250218-141555.png" alt="screenshot-20250218-141555.png" width="736" />
</figure>
3. 생성된 App-Level Token 모달에서, 앱 토큰을 복사하여 따로 기록해 둡니다. App-Level Token은 `xapp-` 으로 시작합니다.

### QueryPie에서 Slack DM 설정하기

1. Admin &gt; General &gt; System &gt; Integrations &gt; Slack 메뉴로 진입한 뒤, `Configure` 버튼을 클릭하여 설정 모달을 엽니다.<br/> <br/>
<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/screenshot-20250310-113509.png" alt="Administrator &gt; General &gt; System &gt; Integrations &gt; Slack &gt; Create a New Slack Configuration" width="736" />
<figcaption>
Administrator &gt; General &gt; System &gt; Integrations &gt; Slack &gt; Create a New Slack Configuration
</figcaption>
</figure>
2. 설정 모달에 아까 기록해둔 App Token과 Bot User OAuth Token을 입력합니다.
3. 추가 설정 값은 다음과 같습니다. DM으로 Workflow 알림을 받고 메시지 안에서 승인/거절을 수행하려면 모든 설정 토글을 활성화합니다.
* **Send Workflow Notification via Slack DM** : 워크플로우 요청에 대한 Slack DM 전송 활성화
* **Allow Users to approve or reject on Slack DM** : Slack DM 내에서 승인 또는 거절 기능 활성화<br/> <br/>
<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/image-20231003-054240.png" alt="액션 허용타입 예시 (좌) / 액션 비허용 타입 예시 (우)" width="760" />
<figcaption>
액션 허용타입 예시 (좌) / 액션 비허용 타입 예시 (우)
</figcaption>
</figure>
4. `Save` 버튼을 누르고 설정을 완료합니다.

### Slack DM 설정 관리하기

1. Slack Configuration을 등록한 뒤, 현재 설정 상태를 화면에서 확인할 수 있습니다.<br/> <br/>
<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/screenshot-20250310-113950.png" alt="Administrator &gt; General &gt; System &gt; Integrations &gt; Slack" width="736" />
<figcaption>
Administrator &gt; General &gt; System &gt; Integrations &gt; Slack
</figcaption>
</figure>
2. `Edit` 버튼을 클릭하여 입력한 설정을 변경할 수 있습니다.<br/>
<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/screenshot-20250218-152840.png" alt="Administrator &gt; General &gt; System &gt; Integrations &gt; Slack &gt; Edit the Slack Configuration" width="736" />
<figcaption>
Administrator &gt; General &gt; System &gt; Integrations &gt; Slack &gt; Edit the Slack Configuration
</figcaption>
</figure>


### Workflow 요청 시 Slack DM 테스트

DB Access Request를 예시로, Slack DM 기능이 정상적으로 작동하는지 테스트를 수행합니다.

1. QueryPie User &gt; Workflow 페이지에서 Submit Request 버튼을 클릭한 뒤, DB Access Request를 선택하여 요청 작성 화면으로 진입합니다. 요청 작성 화면에서 필요한 정보를 입력하고, 요청을 상신합니다. <br/>
<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/screenshot-20250218-151504.png" alt="screenshot-20250218-151504.png" width="736" />
</figure>
2. 승인자는 앞에서 추가한 Slack App과의 DM으로 승인해야 할 요청 알림을 수신할 수 있습니다.<br/>**Allow Users to approve or reject on Slack DM** 설정이 켜져 있으므로, DM에서 직접 사유를 입력하고 요청을 승인 또는 거절할 수 있습니다.<br/> <br/>
<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/screenshot-20250218-152238.png" alt="screenshot-20250218-152238.png" width="701" />
</figure>
3. DM에서 `Details` 버튼을 클릭 시, QueryPie Admin에서 결재 요청에 대한 상세 내용을 확인하고 승인 또는 거절할 수 있습니다.<br/>
<figure data-layout="center" data-align="center">
<img src="/administrator-manual/general/system/integrations/integrating-with-slack-dm/screenshot-20250218-152527.png" alt="screenshot-20250218-152527.png" width="736" />
</figure>
Loading
Loading