@goodandready/dsh-plugin-notify 0.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.
- package/CHANGELOG.md +91 -0
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/README.ru.md +199 -0
- package/README.zh.md +198 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +714 -0
- package/lib/index.js +444 -0
- package/package.json +60 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: '@goodandready/dsh-plugin-notify',
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} }
|
|
5
|
+
const React = require('react')
|
|
6
|
+
const NS = '@goodandready/dsh-plugin-notify'
|
|
7
|
+
const CHANNELS = ['feishu', 'wecom', 'dingtalk', 'slack', 'discord', 'custom']
|
|
8
|
+
|
|
9
|
+
const en = {
|
|
10
|
+
title: 'Notify',
|
|
11
|
+
subtitle: 'IM webhook, sound, toast, and desktop notifications',
|
|
12
|
+
intro: 'Store each webhook URL under Settings → Credentials, then type only the credential name below. Do not paste URLs into this form.',
|
|
13
|
+
'field.feishu': 'Feishu credential',
|
|
14
|
+
'field.wecom': 'WeCom credential',
|
|
15
|
+
'field.dingtalk': 'DingTalk credential',
|
|
16
|
+
'field.slack': 'Slack credential',
|
|
17
|
+
'field.discord': 'Discord credential',
|
|
18
|
+
'field.custom': 'Custom webhook credential',
|
|
19
|
+
'field.events': 'Events (comma-separated)',
|
|
20
|
+
'field.local': 'Local macOS notification (host)',
|
|
21
|
+
'field.enableSound': 'Audio chimes (Web Audio)',
|
|
22
|
+
'field.enableToasts': 'In-app on-screen toasts',
|
|
23
|
+
'field.enableDesktop': 'Desktop OS notifications (Win/Mac/Linux)',
|
|
24
|
+
'field.notifyBackgroundOnly': 'Notify only for background/other sessions',
|
|
25
|
+
'field.timeoutMs': 'Webhook timeout (ms)',
|
|
26
|
+
'field.dndStart': 'DND start (HH:MM)',
|
|
27
|
+
'field.dndEnd': 'DND end (HH:MM)',
|
|
28
|
+
'field.includeSession': 'Include session id',
|
|
29
|
+
'field.includeDuration': 'Include duration',
|
|
30
|
+
'field.excludePrefixes': 'Exclude session prefixes (comma-separated)',
|
|
31
|
+
'hint.cred': 'Credential name only — value must be the full webhook URL.',
|
|
32
|
+
'hint.events': 'task_done, error, approval_requested',
|
|
33
|
+
'btn.testSound': 'Test sound',
|
|
34
|
+
'btn.testToast': 'Test toast',
|
|
35
|
+
'btn.reqPermission': 'Allow desktop notifications',
|
|
36
|
+
'perm.granted': 'Desktop notifications enabled',
|
|
37
|
+
'perm.denied': 'Notifications denied by browser settings',
|
|
38
|
+
'perm.default': 'Permission not granted yet',
|
|
39
|
+
'toast.taskDone': 'Task completed',
|
|
40
|
+
'toast.error': 'Task failed',
|
|
41
|
+
'toast.approval': 'Approval required',
|
|
42
|
+
'toast.switchToSession': 'Switch to session',
|
|
43
|
+
'settings.loading': 'Loading settings…',
|
|
44
|
+
'settings.unavailable': 'Settings scope unavailable for this plugin.',
|
|
45
|
+
'settings.save': 'Save',
|
|
46
|
+
'settings.saving': 'Saving…',
|
|
47
|
+
'settings.saved': 'Saved',
|
|
48
|
+
'settings.saveFailed': 'Save failed: ',
|
|
49
|
+
}
|
|
50
|
+
const zh = {
|
|
51
|
+
title: '通知',
|
|
52
|
+
subtitle: 'IM Webhook、音频、浮动弹窗与桌面系统通知',
|
|
53
|
+
intro: '请先在设置 → 凭据中保存 Webhook URL,然后只在下方填写凭据名称。不要直接粘贴 URL。',
|
|
54
|
+
'field.feishu': '飞书凭据',
|
|
55
|
+
'field.wecom': '企业微信凭据',
|
|
56
|
+
'field.dingtalk': '钉钉凭据',
|
|
57
|
+
'field.slack': 'Slack 凭据',
|
|
58
|
+
'field.discord': 'Discord 凭据',
|
|
59
|
+
'field.custom': '自定义 Webhook 凭据',
|
|
60
|
+
'field.events': '事件(逗号分隔)',
|
|
61
|
+
'field.local': '本地 macOS 通知(主机)',
|
|
62
|
+
'field.enableSound': '音频提示音(Web Audio)',
|
|
63
|
+
'field.enableToasts': '界面内浮动弹窗(Toasts)',
|
|
64
|
+
'field.enableDesktop': '桌面系统通知(Win/Mac/Linux)',
|
|
65
|
+
'field.notifyBackgroundOnly': '仅在后台或其他会话时通知',
|
|
66
|
+
'field.timeoutMs': 'Webhook 超时(毫秒)',
|
|
67
|
+
'field.dndStart': '免打扰开始时间(HH:MM)',
|
|
68
|
+
'field.dndEnd': '免打扰结束时间(HH:MM)',
|
|
69
|
+
'field.includeSession': '包含会话 ID',
|
|
70
|
+
'field.includeDuration': '包含持续时间',
|
|
71
|
+
'field.excludePrefixes': '排除会话前缀(逗号分隔)',
|
|
72
|
+
'hint.cred': '仅填写凭据名称;其值必须是完整的 Webhook URL。',
|
|
73
|
+
'hint.events': 'task_done, error, approval_requested',
|
|
74
|
+
'btn.testSound': '测试声音',
|
|
75
|
+
'btn.testToast': '测试弹窗',
|
|
76
|
+
'btn.reqPermission': '允许桌面通知',
|
|
77
|
+
'perm.granted': '桌面通知已启用',
|
|
78
|
+
'perm.denied': '已被浏览器设置禁止通知',
|
|
79
|
+
'perm.default': '尚未授权通知权限',
|
|
80
|
+
'toast.taskDone': '任务已完成',
|
|
81
|
+
'toast.error': '任务失败',
|
|
82
|
+
'toast.approval': '需要用户审批',
|
|
83
|
+
'toast.switchToSession': '切换至该会话',
|
|
84
|
+
'settings.loading': '正在加载设置…',
|
|
85
|
+
'settings.unavailable': '此插件的设置范围不可用。',
|
|
86
|
+
'settings.save': '保存',
|
|
87
|
+
'settings.saving': '正在保存…',
|
|
88
|
+
'settings.saved': '已保存',
|
|
89
|
+
'settings.saveFailed': '保存失败:',
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let ChevronIcon = null
|
|
93
|
+
try {
|
|
94
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
95
|
+
ChevronIcon = primitives && primitives.IconChevronDownOutline14
|
|
96
|
+
} catch (_) {
|
|
97
|
+
ChevronIcon = null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function FallbackChevron(props) {
|
|
101
|
+
return React.createElement('svg', {
|
|
102
|
+
className: 'pn-chev' + (props.open ? ' pn-chev-open' : ''),
|
|
103
|
+
style: { marginLeft: 'auto', flex: 'none', color: 'var(--dsw-alias-label-tertiary)', transition: 'transform .16s', transform: props.open ? 'rotate(180deg)' : 'none' },
|
|
104
|
+
width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': 'true',
|
|
105
|
+
}, React.createElement('path', {
|
|
106
|
+
d: 'M3.5 5.25L7 8.75L10.5 5.25', stroke: 'currentColor', strokeWidth: 1.5,
|
|
107
|
+
strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
108
|
+
}))
|
|
109
|
+
}
|
|
110
|
+
const Chevron = ChevronIcon || FallbackChevron
|
|
111
|
+
|
|
112
|
+
const cardCss = [
|
|
113
|
+
'.pn-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}',
|
|
114
|
+
'.pn-head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}',
|
|
115
|
+
'.pn-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
|
|
116
|
+
'.pn-sub{color:var(--dsw-alias-label-secondary);font-size:13px}',
|
|
117
|
+
'.pn-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}',
|
|
118
|
+
'.pn-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}',
|
|
119
|
+
'.pn-label{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500}',
|
|
120
|
+
'.pn-hint{color:var(--dsw-alias-label-secondary);font-size:12px}',
|
|
121
|
+
'.pn-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}',
|
|
122
|
+
'.pn-btn-action{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 10px;font-size:12px;background:var(--dsw-alias-bg-layer-4);color:var(--dsw-alias-label-primary)}',
|
|
123
|
+
'.pn-btn-action:hover{background:var(--dsw-alias-bg-layer-2)}',
|
|
124
|
+
'.pn-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px}',
|
|
125
|
+
'.pn-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 14px;font-size:13px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}',
|
|
126
|
+
'.pn-msg-ok{color:var(--dsw-alias-state-success-primary);font-size:12px}',
|
|
127
|
+
'.pn-msg-err{color:var(--dsw-alias-state-danger-primary);font-size:12px}',
|
|
128
|
+
'.pn-chev{margin-left:auto;flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s}',
|
|
129
|
+
'.pn-chev-open{transform:rotate(180deg)}',
|
|
130
|
+
'.pn-toast-container{position:fixed;bottom:24px;right:24px;z-index:99999;display:flex;flex-direction:column-reverse;gap:10px;pointer-events:none;max-width:380px;width:calc(100vw - 48px)}',
|
|
131
|
+
'.pn-toast{pointer-events:auto;background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,0.32);padding:12px 14px;display:flex;flex-direction:column;gap:6px;animation:pnToastIn .24s cubic-bezier(0.16,1,0.3,1);transition:opacity .2s,transform .2s}',
|
|
132
|
+
'.pn-toast.pn-toast-out{opacity:0;transform:translateY(8px)}',
|
|
133
|
+
'.pn-toast-head{display:flex;align-items:center;gap:8px}',
|
|
134
|
+
'.pn-toast-badge{font-size:14px;line-height:1;flex:none}',
|
|
135
|
+
'.pn-toast-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
136
|
+
'.pn-toast-close{background:0 0;border:0;color:var(--dsw-alias-label-tertiary);cursor:pointer;padding:2px 6px;font-size:14px;border-radius:4px;line-height:1}',
|
|
137
|
+
'.pn-toast-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4)}',
|
|
138
|
+
'.pn-toast-body{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}',
|
|
139
|
+
'.pn-toast-foot{display:flex;align-items:center;justify-content:space-between;margin-top:2px;padding-top:6px;border-top:1px solid var(--dsw-alias-border-l2)}',
|
|
140
|
+
'.pn-toast-sess{font-size:11px;color:var(--dsw-alias-label-tertiary);font-family:monospace;overflow:hidden;text-overflow:ellipsis;max-width:180px;white-space:nowrap}',
|
|
141
|
+
'.pn-toast-btn{background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);border-radius:6px;padding:3px 8px;font-size:12px;cursor:pointer;font-weight:500}',
|
|
142
|
+
'.pn-toast-btn:hover{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}',
|
|
143
|
+
'@keyframes pnToastIn{from{opacity:0;transform:translateY(12px) scale(0.96)}to{opacity:1;transform:translateY(0) scale(1)}}',
|
|
144
|
+
].join('')
|
|
145
|
+
|
|
146
|
+
function ensureStyles() {
|
|
147
|
+
if (typeof document === 'undefined') return
|
|
148
|
+
if (document.getElementById('dsh-plugin-notify-card-styles')) return
|
|
149
|
+
const el = document.createElement('style')
|
|
150
|
+
el.id = 'dsh-plugin-notify-card-styles'
|
|
151
|
+
el.dataset.dshPlugin = 'dsh-plugin-notify'
|
|
152
|
+
el.textContent = cardCss
|
|
153
|
+
document.head.appendChild(el)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function makeT(dict, fallback) {
|
|
157
|
+
return function t(key) {
|
|
158
|
+
return (dict && dict[key]) || (fallback && fallback[key]) || key
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function useActiveLocale(ctx) {
|
|
163
|
+
return React.useSyncExternalStore(
|
|
164
|
+
React.useMemo(() => (cb) => (ctx && ctx.locale ? ctx.locale.subscribe(cb) : () => {}), [ctx]),
|
|
165
|
+
React.useCallback(() => (ctx && ctx.locale && ctx.locale.getSnapshot ? ctx.locale.getSnapshot().active : 'en'), [ctx]),
|
|
166
|
+
React.useCallback(() => 'en', []),
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function splitList(s) {
|
|
171
|
+
return String(s || '').split(',').map((x) => x.trim()).filter(Boolean)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let sharedAudioCtx = null
|
|
175
|
+
function playChime(kind) {
|
|
176
|
+
if (typeof window === 'undefined') return
|
|
177
|
+
try {
|
|
178
|
+
const AudioCtx = window.AudioContext || window.webkitAudioContext
|
|
179
|
+
if (!AudioCtx) return
|
|
180
|
+
if (!sharedAudioCtx || sharedAudioCtx.state === 'closed') {
|
|
181
|
+
sharedAudioCtx = new AudioCtx()
|
|
182
|
+
}
|
|
183
|
+
if (sharedAudioCtx.state === 'suspended') {
|
|
184
|
+
sharedAudioCtx.resume().catch(() => {})
|
|
185
|
+
}
|
|
186
|
+
const actx = sharedAudioCtx
|
|
187
|
+
const now = actx.currentTime
|
|
188
|
+
let notes = [523.25, 659.25, 783.99, 1046.50]
|
|
189
|
+
let step = 0.10
|
|
190
|
+
let duration = 0.35
|
|
191
|
+
|
|
192
|
+
if (kind === 'error') {
|
|
193
|
+
notes = [392.00, 311.13, 261.63]
|
|
194
|
+
step = 0.12
|
|
195
|
+
duration = 0.40
|
|
196
|
+
} else if (kind === 'approval_requested') {
|
|
197
|
+
notes = [880.00, 1318.51]
|
|
198
|
+
step = 0.15
|
|
199
|
+
duration = 0.45
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
notes.forEach((freq, idx) => {
|
|
203
|
+
const osc = actx.createOscillator()
|
|
204
|
+
const gain = actx.createGain()
|
|
205
|
+
osc.type = 'sine'
|
|
206
|
+
osc.frequency.setValueAtTime(freq, now + idx * step)
|
|
207
|
+
gain.gain.setValueAtTime(0.001, now + idx * step)
|
|
208
|
+
gain.gain.exponentialRampToValueAtTime(0.12, now + idx * step + 0.02)
|
|
209
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, now + idx * step + duration)
|
|
210
|
+
osc.connect(gain)
|
|
211
|
+
gain.connect(actx.destination)
|
|
212
|
+
osc.start(now + idx * step)
|
|
213
|
+
osc.stop(now + idx * step + duration + 0.05)
|
|
214
|
+
})
|
|
215
|
+
} catch (_err) {
|
|
216
|
+
// best effort: audio playback may be blocked by browser policy
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getActiveSessionId() {
|
|
221
|
+
if (typeof window === 'undefined') return ''
|
|
222
|
+
try {
|
|
223
|
+
const hash = window.location.hash || ''
|
|
224
|
+
const m = hash.match(/session[/-]([a-zA-Z0-9_-]+)/)
|
|
225
|
+
if (m) return m[1]
|
|
226
|
+
const params = new URLSearchParams(window.location.search)
|
|
227
|
+
return params.get('session') || params.get('sessionId') || ''
|
|
228
|
+
} catch (_) {
|
|
229
|
+
return ''
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function navigateToSession(ctx, sessionId) {
|
|
234
|
+
if (!sessionId) return
|
|
235
|
+
try {
|
|
236
|
+
if (ctx && ctx.sessions && typeof ctx.sessions.activate === 'function') {
|
|
237
|
+
ctx.sessions.activate(sessionId)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
} catch (_err) {
|
|
241
|
+
// best effort: session navigation fallback
|
|
242
|
+
}
|
|
243
|
+
if (typeof window !== 'undefined' && window.location) {
|
|
244
|
+
window.location.hash = `session-${sessionId}`
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function getToastContainer() {
|
|
249
|
+
if (typeof document === 'undefined') return null
|
|
250
|
+
let container = document.getElementById('dsh-notify-toast-container')
|
|
251
|
+
if (!container) {
|
|
252
|
+
container = document.createElement('div')
|
|
253
|
+
container.id = 'dsh-notify-toast-container'
|
|
254
|
+
container.className = 'pn-toast-container'
|
|
255
|
+
container.dataset.dshPlugin = 'dsh-plugin-notify'
|
|
256
|
+
document.body.appendChild(container)
|
|
257
|
+
}
|
|
258
|
+
return container
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function showInAppToast(event, t, ctx) {
|
|
262
|
+
if (typeof document === 'undefined') return
|
|
263
|
+
ensureStyles()
|
|
264
|
+
const container = getToastContainer()
|
|
265
|
+
if (!container) return
|
|
266
|
+
|
|
267
|
+
const toast = document.createElement('div')
|
|
268
|
+
toast.className = 'pn-toast'
|
|
269
|
+
toast.dataset.dshPlugin = 'dsh-plugin-notify'
|
|
270
|
+
toast.setAttribute('role', 'alert')
|
|
271
|
+
|
|
272
|
+
const badge = event.kind === 'task_done' ? '✅' : event.kind === 'error' ? '⚠️' : '⏸️'
|
|
273
|
+
const kindLabel = event.kind === 'task_done'
|
|
274
|
+
? (t('toast.taskDone') || 'Task completed')
|
|
275
|
+
: event.kind === 'error'
|
|
276
|
+
? (t('toast.error') || 'Task failed')
|
|
277
|
+
: (t('toast.approval') || 'Approval required')
|
|
278
|
+
|
|
279
|
+
const titleText = `${badge} ${kindLabel}: ${event.title || event.sessionId}`
|
|
280
|
+
|
|
281
|
+
const head = document.createElement('div')
|
|
282
|
+
head.className = 'pn-toast-head'
|
|
283
|
+
|
|
284
|
+
const titleSpan = document.createElement('span')
|
|
285
|
+
titleSpan.className = 'pn-toast-title'
|
|
286
|
+
titleSpan.textContent = titleText
|
|
287
|
+
|
|
288
|
+
const closeBtn = document.createElement('button')
|
|
289
|
+
closeBtn.className = 'pn-toast-close'
|
|
290
|
+
closeBtn.setAttribute('type', 'button')
|
|
291
|
+
closeBtn.setAttribute('aria-label', 'Close')
|
|
292
|
+
closeBtn.textContent = '✕'
|
|
293
|
+
|
|
294
|
+
head.appendChild(titleSpan)
|
|
295
|
+
head.appendChild(closeBtn)
|
|
296
|
+
toast.appendChild(head)
|
|
297
|
+
|
|
298
|
+
if (event.summary) {
|
|
299
|
+
const body = document.createElement('div')
|
|
300
|
+
body.className = 'pn-toast-body'
|
|
301
|
+
body.textContent = event.summary
|
|
302
|
+
toast.appendChild(body)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const foot = document.createElement('div')
|
|
306
|
+
foot.className = 'pn-toast-foot'
|
|
307
|
+
|
|
308
|
+
const sessSpan = document.createElement('span')
|
|
309
|
+
sessSpan.className = 'pn-toast-sess'
|
|
310
|
+
sessSpan.textContent = `ID: ${String(event.sessionId || '').slice(0, 16)}`
|
|
311
|
+
foot.appendChild(sessSpan)
|
|
312
|
+
|
|
313
|
+
if (event.sessionId) {
|
|
314
|
+
const switchBtn = document.createElement('button')
|
|
315
|
+
switchBtn.className = 'pn-toast-btn'
|
|
316
|
+
switchBtn.setAttribute('type', 'button')
|
|
317
|
+
switchBtn.textContent = t('toast.switchToSession') || 'Switch to session'
|
|
318
|
+
switchBtn.onclick = (e) => {
|
|
319
|
+
e.stopPropagation()
|
|
320
|
+
dismiss()
|
|
321
|
+
navigateToSession(ctx, event.sessionId)
|
|
322
|
+
}
|
|
323
|
+
foot.appendChild(switchBtn)
|
|
324
|
+
}
|
|
325
|
+
toast.appendChild(foot)
|
|
326
|
+
|
|
327
|
+
let dismissed = false
|
|
328
|
+
const dismiss = () => {
|
|
329
|
+
if (dismissed) return
|
|
330
|
+
dismissed = true
|
|
331
|
+
toast.classList.add('pn-toast-out')
|
|
332
|
+
setTimeout(() => {
|
|
333
|
+
if (toast.parentNode) toast.parentNode.removeChild(toast)
|
|
334
|
+
}, 220)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
closeBtn.onclick = (e) => {
|
|
338
|
+
e.stopPropagation()
|
|
339
|
+
dismiss()
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
toast.onclick = () => {
|
|
343
|
+
if (event.sessionId) {
|
|
344
|
+
dismiss()
|
|
345
|
+
navigateToSession(ctx, event.sessionId)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
container.appendChild(toast)
|
|
350
|
+
setTimeout(dismiss, 7000)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function showDesktopNotification(event, t, ctx) {
|
|
354
|
+
if (typeof window === 'undefined' || !('Notification' in window)) return
|
|
355
|
+
if (Notification.permission !== 'granted') return
|
|
356
|
+
|
|
357
|
+
try {
|
|
358
|
+
const kindLabel = event.kind === 'task_done'
|
|
359
|
+
? (t('toast.taskDone') || 'Task completed')
|
|
360
|
+
: event.kind === 'error'
|
|
361
|
+
? (t('toast.error') || 'Task failed')
|
|
362
|
+
: (t('toast.approval') || 'Approval required')
|
|
363
|
+
|
|
364
|
+
const badge = event.kind === 'task_done' ? '✅' : event.kind === 'error' ? '⚠️' : '⏸️'
|
|
365
|
+
const notifTitle = `${badge} ${kindLabel}: ${event.title || event.sessionId}`
|
|
366
|
+
|
|
367
|
+
const bodyParts = []
|
|
368
|
+
if (event.summary) bodyParts.push(event.summary)
|
|
369
|
+
if (event.durationMs) bodyParts.push(`Duration: ${Math.round(event.durationMs / 1000)}s`)
|
|
370
|
+
if (event.sessionId) bodyParts.push(`Session: ${event.sessionId}`)
|
|
371
|
+
|
|
372
|
+
const notif = new Notification(notifTitle, {
|
|
373
|
+
body: bodyParts.join('\n'),
|
|
374
|
+
tag: `dsh-notify-${event.sessionId || 'any'}`,
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
notif.onclick = () => {
|
|
378
|
+
try {
|
|
379
|
+
if (window.focus) window.focus()
|
|
380
|
+
} catch (_err) {
|
|
381
|
+
// best effort: window focus may be denied
|
|
382
|
+
}
|
|
383
|
+
if (event.sessionId) navigateToSession(ctx, event.sessionId)
|
|
384
|
+
notif.close()
|
|
385
|
+
}
|
|
386
|
+
} catch (_err) {
|
|
387
|
+
// best effort: notification display error
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let activeEventSource = null
|
|
392
|
+
|
|
393
|
+
function startSseListener(ctx, t, getSettings) {
|
|
394
|
+
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return () => {}
|
|
395
|
+
if (activeEventSource) return () => {}
|
|
396
|
+
|
|
397
|
+
let stopped = false
|
|
398
|
+
let es = null
|
|
399
|
+
|
|
400
|
+
function connect() {
|
|
401
|
+
if (stopped) return
|
|
402
|
+
try {
|
|
403
|
+
es = new EventSource('/dsh-plugin-notify/events')
|
|
404
|
+
activeEventSource = es
|
|
405
|
+
|
|
406
|
+
es.onmessage = (e) => {
|
|
407
|
+
try {
|
|
408
|
+
if (!e.data || e.data.startsWith(':')) return
|
|
409
|
+
const payload = JSON.parse(e.data)
|
|
410
|
+
handleIncomingEvent(payload, ctx, t, getSettings)
|
|
411
|
+
} catch (_err) {
|
|
412
|
+
// ignore malformed SSE payload
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
es.onerror = () => {
|
|
417
|
+
if (es) {
|
|
418
|
+
es.close()
|
|
419
|
+
es = null
|
|
420
|
+
activeEventSource = null
|
|
421
|
+
}
|
|
422
|
+
if (!stopped) {
|
|
423
|
+
setTimeout(connect, 5000)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
} catch (_err) {
|
|
427
|
+
// best effort: reconnect if EventSource constructor fails
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
connect()
|
|
432
|
+
|
|
433
|
+
return () => {
|
|
434
|
+
stopped = true
|
|
435
|
+
if (es) {
|
|
436
|
+
es.close()
|
|
437
|
+
es = null
|
|
438
|
+
activeEventSource = null
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function handleIncomingEvent(event, ctx, t, getSettings) {
|
|
444
|
+
const cfg = (getSettings && getSettings()) || {}
|
|
445
|
+
const activeSid = getActiveSessionId()
|
|
446
|
+
|
|
447
|
+
const isBackground = !activeSid || String(event.sessionId) !== String(activeSid)
|
|
448
|
+
const notifyBgOnly = !!cfg.notifyBackgroundOnly
|
|
449
|
+
|
|
450
|
+
if (notifyBgOnly && !isBackground) {
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// 1. Audio chime
|
|
455
|
+
if (Boolean(cfg.enableSound)) {
|
|
456
|
+
playChime(event.kind)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// 2. In-app toast
|
|
460
|
+
if (Boolean(cfg.enableToasts)) {
|
|
461
|
+
showInAppToast(event, t, ctx)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 3. Desktop OS notification
|
|
465
|
+
if (Boolean(cfg.enableDesktopNotifications)) {
|
|
466
|
+
showDesktopNotification(event, t, ctx)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function NotifyCard(props) {
|
|
471
|
+
const ctx = props.ctx
|
|
472
|
+
const locale = useActiveLocale(ctx)
|
|
473
|
+
const t = props.t || makeT(locale === 'zh' ? zh : en, en)
|
|
474
|
+
const [open, setOpen] = React.useState(false)
|
|
475
|
+
const [draft, setDraft] = React.useState(null)
|
|
476
|
+
const [saving, setSaving] = React.useState(false)
|
|
477
|
+
const [err, setErr] = React.useState('')
|
|
478
|
+
const [saved, setSaved] = React.useState(false)
|
|
479
|
+
const [permState, setPermState] = React.useState(() => {
|
|
480
|
+
if (typeof window !== 'undefined' && 'Notification' in window) {
|
|
481
|
+
return Notification.permission
|
|
482
|
+
}
|
|
483
|
+
return 'unsupported'
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
const requestPermission = async () => {
|
|
487
|
+
if (typeof window !== 'undefined' && 'Notification' in window) {
|
|
488
|
+
try {
|
|
489
|
+
const res = await Notification.requestPermission()
|
|
490
|
+
setPermState(res)
|
|
491
|
+
} catch (_err) {
|
|
492
|
+
// best effort: permission request failed
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const scope = React.useMemo(
|
|
498
|
+
() => (ctx && ctx.settingsScope ? ctx.settingsScope.bind({ namespace: NS }) : undefined),
|
|
499
|
+
[ctx],
|
|
500
|
+
)
|
|
501
|
+
const snapshot = React.useSyncExternalStore(
|
|
502
|
+
React.useMemo(() => (cb) => (scope ? scope.subscribe(cb) : () => {}), [scope]),
|
|
503
|
+
React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'loading' }), [scope]),
|
|
504
|
+
React.useCallback(() => ({ status: 'loading' }), []),
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
React.useEffect(() => { ensureStyles() }, [])
|
|
508
|
+
|
|
509
|
+
const status = (snapshot && snapshot.status) || 'loading'
|
|
510
|
+
const stored = (snapshot && snapshot.value) || {}
|
|
511
|
+
|
|
512
|
+
React.useEffect(() => {
|
|
513
|
+
if (status === 'ready' && draft === null) {
|
|
514
|
+
const wh = stored.webhooks || {}
|
|
515
|
+
setDraft({
|
|
516
|
+
feishu: wh.feishu || '',
|
|
517
|
+
wecom: wh.wecom || '',
|
|
518
|
+
dingtalk: wh.dingtalk || '',
|
|
519
|
+
slack: wh.slack || '',
|
|
520
|
+
discord: wh.discord || '',
|
|
521
|
+
custom: wh.custom || '',
|
|
522
|
+
events: Array.isArray(stored.events) ? stored.events.join(', ') : 'task_done, error, approval_requested',
|
|
523
|
+
local: stored.local !== false,
|
|
524
|
+
enableSound: Boolean(stored.enableSound),
|
|
525
|
+
enableToasts: Boolean(stored.enableToasts),
|
|
526
|
+
enableDesktopNotifications: Boolean(stored.enableDesktopNotifications),
|
|
527
|
+
notifyBackgroundOnly: !!stored.notifyBackgroundOnly,
|
|
528
|
+
timeoutMs: String(stored.timeoutMs != null ? stored.timeoutMs : 5000),
|
|
529
|
+
dndStart: (stored.dnd && stored.dnd.start) || '',
|
|
530
|
+
dndEnd: (stored.dnd && stored.dnd.end) || '',
|
|
531
|
+
includeSession: stored.includeSession !== false,
|
|
532
|
+
includeDuration: stored.includeDuration !== false,
|
|
533
|
+
excludePrefixes: Array.isArray(stored.excludeSessionPrefixes) ? stored.excludeSessionPrefixes.join(', ') : '',
|
|
534
|
+
})
|
|
535
|
+
}
|
|
536
|
+
}, [status, stored, draft])
|
|
537
|
+
|
|
538
|
+
const save = async () => {
|
|
539
|
+
if (!scope || !draft) return
|
|
540
|
+
setSaving(true); setErr(''); setSaved(false)
|
|
541
|
+
const broken = []
|
|
542
|
+
const webhooks = {}
|
|
543
|
+
for (const ch of CHANNELS) webhooks[ch] = String(draft[ch] || '').trim()
|
|
544
|
+
const timeoutNum = Number(String(draft.timeoutMs).trim())
|
|
545
|
+
const payload = {
|
|
546
|
+
webhooks,
|
|
547
|
+
events: splitList(draft.events),
|
|
548
|
+
local: !!draft.local,
|
|
549
|
+
enableSound: !!draft.enableSound,
|
|
550
|
+
enableToasts: !!draft.enableToasts,
|
|
551
|
+
enableDesktopNotifications: !!draft.enableDesktopNotifications,
|
|
552
|
+
notifyBackgroundOnly: !!draft.notifyBackgroundOnly,
|
|
553
|
+
timeoutMs: Number.isFinite(timeoutNum) && timeoutNum > 0 ? timeoutNum : 5000,
|
|
554
|
+
dnd: { start: String(draft.dndStart || '').trim(), end: String(draft.dndEnd || '').trim() },
|
|
555
|
+
includeSession: !!draft.includeSession,
|
|
556
|
+
includeDuration: !!draft.includeDuration,
|
|
557
|
+
excludeSessionPrefixes: splitList(draft.excludePrefixes),
|
|
558
|
+
}
|
|
559
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
560
|
+
try { await scope.set(k, v) }
|
|
561
|
+
catch (e) { broken.push(k + ': ' + (e && e.message || String(e))) }
|
|
562
|
+
}
|
|
563
|
+
setSaving(false)
|
|
564
|
+
if (broken.length) { setErr(t('settings.saveFailed') + broken.join('; ')); return }
|
|
565
|
+
setSaved(true)
|
|
566
|
+
setTimeout(() => setSaved(false), 2500)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const field = (key, input) => React.createElement('div', { className: 'pn-field' },
|
|
570
|
+
React.createElement('label', { className: 'pn-label' }, t('field.' + key)),
|
|
571
|
+
input,
|
|
572
|
+
key === 'events' ? React.createElement('span', { className: 'pn-hint' }, t('hint.events'))
|
|
573
|
+
: CHANNELS.includes(key) ? React.createElement('span', { className: 'pn-hint' }, t('hint.cred'))
|
|
574
|
+
: null,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
return React.createElement('li', { className: 'pn-card' },
|
|
578
|
+
React.createElement('button', { type: 'button', className: 'pn-head', 'aria-expanded': open, onClick: () => setOpen((v) => !v) },
|
|
579
|
+
React.createElement('span', { style: { display: 'flex', flexDirection: 'column' } },
|
|
580
|
+
React.createElement('span', { className: 'pn-title' }, t('title')),
|
|
581
|
+
React.createElement('span', { className: 'pn-sub' }, t('subtitle')),
|
|
582
|
+
),
|
|
583
|
+
React.createElement(Chevron, { open }),
|
|
584
|
+
),
|
|
585
|
+
open ? React.createElement('div', { className: 'pn-body' },
|
|
586
|
+
status === 'loading'
|
|
587
|
+
? React.createElement('p', { className: 'pn-hint', style: { padding: '12px 0' } }, t('settings.loading'))
|
|
588
|
+
: status !== 'ready'
|
|
589
|
+
? React.createElement('p', { className: 'pn-msg-err', style: { padding: '12px 0' } }, t('settings.unavailable'))
|
|
590
|
+
: React.createElement('div', null,
|
|
591
|
+
React.createElement('p', { className: 'pn-hint', style: { padding: '12px 0 0' } }, t('intro')),
|
|
592
|
+
...CHANNELS.map((ch) => field(ch, React.createElement('input', {
|
|
593
|
+
className: 'pn-input', value: (draft && draft[ch]) || '',
|
|
594
|
+
onChange: (e) => setDraft({ ...draft, [ch]: e.target.value }),
|
|
595
|
+
autoComplete: 'off',
|
|
596
|
+
}))),
|
|
597
|
+
field('events', React.createElement('input', {
|
|
598
|
+
className: 'pn-input', value: (draft && draft.events) || '',
|
|
599
|
+
onChange: (e) => setDraft({ ...draft, events: e.target.value }),
|
|
600
|
+
})),
|
|
601
|
+
field('timeoutMs', React.createElement('input', {
|
|
602
|
+
className: 'pn-input', type: 'number', value: (draft && draft.timeoutMs) || '',
|
|
603
|
+
onChange: (e) => setDraft({ ...draft, timeoutMs: e.target.value }),
|
|
604
|
+
})),
|
|
605
|
+
field('dndStart', React.createElement('input', {
|
|
606
|
+
className: 'pn-input', value: (draft && draft.dndStart) || '',
|
|
607
|
+
onChange: (e) => setDraft({ ...draft, dndStart: e.target.value }),
|
|
608
|
+
})),
|
|
609
|
+
field('dndEnd', React.createElement('input', {
|
|
610
|
+
className: 'pn-input', value: (draft && draft.dndEnd) || '',
|
|
611
|
+
onChange: (e) => setDraft({ ...draft, dndEnd: e.target.value }),
|
|
612
|
+
})),
|
|
613
|
+
field('excludePrefixes', React.createElement('input', {
|
|
614
|
+
className: 'pn-input', value: (draft && draft.excludePrefixes) || '',
|
|
615
|
+
onChange: (e) => setDraft({ ...draft, excludePrefixes: e.target.value }),
|
|
616
|
+
})),
|
|
617
|
+
React.createElement('div', { className: 'pn-field', style: { flexDirection: 'row', alignItems: 'center', gap: 8, justifyContent: 'space-between' } },
|
|
618
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
|
|
619
|
+
React.createElement('input', { type: 'checkbox', id: 'pn-sound', checked: !!(draft && draft.enableSound), onChange: (e) => setDraft({ ...draft, enableSound: e.target.checked }) }),
|
|
620
|
+
React.createElement('label', { htmlFor: 'pn-sound', className: 'pn-label' }, t('field.enableSound')),
|
|
621
|
+
),
|
|
622
|
+
React.createElement('button', { type: 'button', className: 'pn-btn-action', onClick: () => playChime('task_done') }, t('btn.testSound')),
|
|
623
|
+
),
|
|
624
|
+
React.createElement('div', { className: 'pn-field', style: { flexDirection: 'row', alignItems: 'center', gap: 8, justifyContent: 'space-between' } },
|
|
625
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
|
|
626
|
+
React.createElement('input', { type: 'checkbox', id: 'pn-toasts', checked: !!(draft && draft.enableToasts), onChange: (e) => setDraft({ ...draft, enableToasts: e.target.checked }) }),
|
|
627
|
+
React.createElement('label', { htmlFor: 'pn-toasts', className: 'pn-label' }, t('field.enableToasts')),
|
|
628
|
+
),
|
|
629
|
+
React.createElement('button', { type: 'button', className: 'pn-btn-action', onClick: () => showInAppToast({ kind: 'task_done', title: 'Example Task', summary: 'Turn completed with 2 tools', sessionId: 'sess-demo-123' }, t, ctx) }, t('btn.testToast')),
|
|
630
|
+
),
|
|
631
|
+
React.createElement('div', { className: 'pn-field', style: { flexDirection: 'row', alignItems: 'center', gap: 8, justifyContent: 'space-between' } },
|
|
632
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
|
|
633
|
+
React.createElement('input', { type: 'checkbox', id: 'pn-desktop', checked: !!(draft && draft.enableDesktopNotifications), onChange: (e) => setDraft({ ...draft, enableDesktopNotifications: e.target.checked }) }),
|
|
634
|
+
React.createElement('label', { htmlFor: 'pn-desktop', className: 'pn-label' }, t('field.enableDesktop')),
|
|
635
|
+
),
|
|
636
|
+
permState === 'default'
|
|
637
|
+
? React.createElement('button', { type: 'button', className: 'pn-btn-action', onClick: requestPermission }, t('btn.reqPermission'))
|
|
638
|
+
: React.createElement('span', { className: 'pn-hint' }, permState === 'granted' ? t('perm.granted') : t('perm.denied')),
|
|
639
|
+
),
|
|
640
|
+
React.createElement('div', { className: 'pn-field', style: { flexDirection: 'row', alignItems: 'center', gap: 8 } },
|
|
641
|
+
React.createElement('input', { type: 'checkbox', id: 'pn-bgonly', checked: !!(draft && draft.notifyBackgroundOnly), onChange: (e) => setDraft({ ...draft, notifyBackgroundOnly: e.target.checked }) }),
|
|
642
|
+
React.createElement('label', { htmlFor: 'pn-bgonly', className: 'pn-label' }, t('field.notifyBackgroundOnly')),
|
|
643
|
+
),
|
|
644
|
+
React.createElement('div', { className: 'pn-field', style: { flexDirection: 'row', alignItems: 'center', gap: 8 } },
|
|
645
|
+
React.createElement('input', { type: 'checkbox', id: 'pn-local', checked: !!(draft && draft.local), onChange: (e) => setDraft({ ...draft, local: e.target.checked }) }),
|
|
646
|
+
React.createElement('label', { htmlFor: 'pn-local', className: 'pn-label' }, t('field.local')),
|
|
647
|
+
),
|
|
648
|
+
React.createElement('div', { className: 'pn-field', style: { flexDirection: 'row', alignItems: 'center', gap: 8 } },
|
|
649
|
+
React.createElement('input', { type: 'checkbox', id: 'pn-sess', checked: !!(draft && draft.includeSession), onChange: (e) => setDraft({ ...draft, includeSession: e.target.checked }) }),
|
|
650
|
+
React.createElement('label', { htmlFor: 'pn-sess', className: 'pn-label' }, t('field.includeSession')),
|
|
651
|
+
),
|
|
652
|
+
React.createElement('div', { className: 'pn-field', style: { flexDirection: 'row', alignItems: 'center', gap: 8 } },
|
|
653
|
+
React.createElement('input', { type: 'checkbox', id: 'pn-dur', checked: !!(draft && draft.includeDuration), onChange: (e) => setDraft({ ...draft, includeDuration: e.target.checked }) }),
|
|
654
|
+
React.createElement('label', { htmlFor: 'pn-dur', className: 'pn-label' }, t('field.includeDuration')),
|
|
655
|
+
),
|
|
656
|
+
err ? React.createElement('div', { className: 'pn-msg-err' }, err) : null,
|
|
657
|
+
saved ? React.createElement('div', { className: 'pn-msg-ok' }, t('settings.saved')) : null,
|
|
658
|
+
React.createElement('div', { className: 'pn-foot' },
|
|
659
|
+
React.createElement('button', { type: 'button', className: 'pn-save', disabled: saving, onClick: save }, saving ? t('settings.saving') : t('settings.save')),
|
|
660
|
+
),
|
|
661
|
+
),
|
|
662
|
+
) : null,
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function apply(ctx) {
|
|
667
|
+
const t = ctx.locale ? ctx.locale.bind(NS) : ((k) => k)
|
|
668
|
+
ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-plugin-notify: dictionaries')
|
|
669
|
+
|
|
670
|
+
try {
|
|
671
|
+
ctx.slots.inject('settings.plugin.item', () => {
|
|
672
|
+
ctx.slots.register(
|
|
673
|
+
{ name: 'settings.plugin.item', key: NS, locale: NS, order: 40, inject: () => ({ ctx }) },
|
|
674
|
+
(props) => React.createElement(NotifyCard, { ...props, ctx }),
|
|
675
|
+
)
|
|
676
|
+
})
|
|
677
|
+
} catch (e) {
|
|
678
|
+
console.warn('[dsh-plugin-notify] settings.plugin.item failed', e)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
let currentSettings = {}
|
|
682
|
+
if (ctx.settingsScope) {
|
|
683
|
+
try {
|
|
684
|
+
const scope = ctx.settingsScope.bind({ namespace: NS })
|
|
685
|
+
if (scope && typeof scope.subscribe === 'function') {
|
|
686
|
+
scope.subscribe(() => {
|
|
687
|
+
const snap = scope.getSnapshot ? scope.getSnapshot() : null
|
|
688
|
+
if (snap && snap.status === 'ready' && snap.value) {
|
|
689
|
+
currentSettings = snap.value
|
|
690
|
+
}
|
|
691
|
+
})
|
|
692
|
+
}
|
|
693
|
+
const initial = scope && typeof scope.getSnapshot === 'function' ? scope.getSnapshot() : null
|
|
694
|
+
if (initial && initial.status === 'ready' && initial.value) {
|
|
695
|
+
currentSettings = initial.value
|
|
696
|
+
}
|
|
697
|
+
} catch (_err) {
|
|
698
|
+
// best effort: fallback if snapshot fails
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (typeof ctx.effect === 'function') {
|
|
703
|
+
ctx.effect(() => {
|
|
704
|
+
return startSseListener(ctx, t, () => currentSettings)
|
|
705
|
+
}, 'dsh-plugin-notify: sse')
|
|
706
|
+
} else {
|
|
707
|
+
startSseListener(ctx, t, () => currentSettings)
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
module.exports = { apply, inject: ['slots', 'locale', 'settingsScope'] }
|
|
712
|
+
return module.exports
|
|
713
|
+
},
|
|
714
|
+
})
|