@awiki/cli 0.0.1-beta.2

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.
Files changed (119) hide show
  1. package/.github/workflows/release.yml +44 -0
  2. package/.goreleaser.yml +44 -0
  3. package/AGENTS.md +60 -0
  4. package/CLAUDE.md +192 -0
  5. package/README.md +2 -0
  6. package/docs/architecture/awiki-command-v2.md +955 -0
  7. package/docs/architecture/awiki-skill-architecture.md +475 -0
  8. package/docs/architecture/awiki-v2-architecture.md +1063 -0
  9. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/cli-init.md +1008 -0
  10. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/output-format.md +407 -0
  11. package/docs/architecture//345/217/202/350/200/203/346/226/207/346/241/243/overall-init.md +741 -0
  12. package/docs/harness/review-spec.md +474 -0
  13. package/docs/installation.md +372 -0
  14. package/docs/plan/awiki-v2-implementation-plan.md +903 -0
  15. package/docs/plan/phase-0/adr-index.md +56 -0
  16. package/docs/plan/phase-0/audit-findings.md +251 -0
  17. package/docs/plan/phase-0/capability-mapping.md +108 -0
  18. package/docs/plan/phase-0/implementation-constraints.md +363 -0
  19. package/docs/publish.md +169 -0
  20. package/go.mod +29 -0
  21. package/go.sum +73 -0
  22. package/internal/anpsdk/registry.go +63 -0
  23. package/internal/authsdk/session.go +351 -0
  24. package/internal/buildinfo/buildinfo.go +34 -0
  25. package/internal/cli/app.go +136 -0
  26. package/internal/cli/app_test.go +88 -0
  27. package/internal/cli/debug.go +104 -0
  28. package/internal/cli/group.go +263 -0
  29. package/internal/cli/id.go +473 -0
  30. package/internal/cli/init.go +134 -0
  31. package/internal/cli/msg.go +228 -0
  32. package/internal/cli/page.go +267 -0
  33. package/internal/cli/root.go +499 -0
  34. package/internal/cli/runtime.go +232 -0
  35. package/internal/cli/upgrade.go +60 -0
  36. package/internal/cmdmeta/catalog.go +203 -0
  37. package/internal/cmdmeta/catalog_test.go +21 -0
  38. package/internal/config/config.go +399 -0
  39. package/internal/config/config_test.go +104 -0
  40. package/internal/config/write.go +37 -0
  41. package/internal/content/service.go +314 -0
  42. package/internal/content/service_test.go +165 -0
  43. package/internal/content/types.go +44 -0
  44. package/internal/docs/topics.go +110 -0
  45. package/internal/doctor/doctor.go +306 -0
  46. package/internal/identity/client.go +267 -0
  47. package/internal/identity/did.go +85 -0
  48. package/internal/identity/did_test.go +50 -0
  49. package/internal/identity/layout.go +206 -0
  50. package/internal/identity/legacy.go +378 -0
  51. package/internal/identity/public.go +70 -0
  52. package/internal/identity/public_test.go +73 -0
  53. package/internal/identity/readiness.go +74 -0
  54. package/internal/identity/service.go +826 -0
  55. package/internal/identity/store.go +385 -0
  56. package/internal/identity/store_test.go +180 -0
  57. package/internal/identity/types.go +204 -0
  58. package/internal/message/auth.go +167 -0
  59. package/internal/message/group_service.go +838 -0
  60. package/internal/message/group_wire.go +350 -0
  61. package/internal/message/group_wire_test.go +67 -0
  62. package/internal/message/helpers.go +61 -0
  63. package/internal/message/http_client.go +334 -0
  64. package/internal/message/proof.go +156 -0
  65. package/internal/message/proof_test.go +61 -0
  66. package/internal/message/service.go +696 -0
  67. package/internal/message/service_test.go +97 -0
  68. package/internal/message/types.go +155 -0
  69. package/internal/message/wire.go +100 -0
  70. package/internal/message/wire_test.go +49 -0
  71. package/internal/message/ws_proxy_client.go +151 -0
  72. package/internal/output/output.go +350 -0
  73. package/internal/output/output_test.go +48 -0
  74. package/internal/runtime/config.go +117 -0
  75. package/internal/runtime/config_test.go +46 -0
  76. package/internal/runtime/listener/files.go +65 -0
  77. package/internal/runtime/listener/manager.go +142 -0
  78. package/internal/runtime/listener/server.go +983 -0
  79. package/internal/runtime/listener/server_test.go +319 -0
  80. package/internal/runtime/listener/sysproc_unix.go +17 -0
  81. package/internal/runtime/listener/sysproc_windows.go +13 -0
  82. package/internal/runtime/listener/types.go +21 -0
  83. package/internal/runtime/listener/wsclient.go +299 -0
  84. package/internal/runtime/listener/wsclient_test.go +41 -0
  85. package/internal/store/dao.go +632 -0
  86. package/internal/store/dao_test.go +87 -0
  87. package/internal/store/helpers.go +197 -0
  88. package/internal/store/import.go +499 -0
  89. package/internal/store/import_test.go +103 -0
  90. package/internal/store/open.go +71 -0
  91. package/internal/store/query.go +151 -0
  92. package/internal/store/schema.go +277 -0
  93. package/internal/store/schema_test.go +56 -0
  94. package/internal/store/types.go +177 -0
  95. package/internal/update/update.go +368 -0
  96. package/package.json +17 -0
  97. package/scripts/install.js +171 -0
  98. package/scripts/release/release-prerelease.sh +86 -0
  99. package/scripts/release/tag-release.sh +66 -0
  100. package/scripts/release/withdraw-release.sh +78 -0
  101. package/scripts/run.js +69 -0
  102. package/skills/README.md +32 -0
  103. package/skills/awiki-bundle/SKILL.md +76 -0
  104. package/skills/awiki-debug/SKILL.md +80 -0
  105. package/skills/awiki-group/SKILL.md +111 -0
  106. package/skills/awiki-id/SKILL.md +123 -0
  107. package/skills/awiki-msg/SKILL.md +131 -0
  108. package/skills/awiki-page/SKILL.md +93 -0
  109. package/skills/awiki-people/SKILL.md +66 -0
  110. package/skills/awiki-runtime/SKILL.md +137 -0
  111. package/skills/awiki-shared/SKILL.md +124 -0
  112. package/skills/awiki-workflow-discovery/SKILL.md +93 -0
  113. package/skills/awiki-workflow-onboarding/SKILL.md +119 -0
  114. package/skills/manifests/skills.yaml +260 -0
  115. package/skills/templates/bundle-skill-template.md +42 -0
  116. package/skills/templates/debug-skill-template.md +44 -0
  117. package/skills/templates/domain-skill-template.md +56 -0
  118. package/skills/templates/shared-skill-template.md +46 -0
  119. package/skills/templates/workflow-skill-template.md +46 -0
@@ -0,0 +1,696 @@
1
+ package message
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "strings"
9
+ "time"
10
+
11
+ appconfig "github.com/agentconnect/awiki-cli/internal/config"
12
+ "github.com/agentconnect/awiki-cli/internal/identity"
13
+ "github.com/agentconnect/awiki-cli/internal/runtime"
14
+ "github.com/agentconnect/awiki-cli/internal/store"
15
+ )
16
+
17
+ type Service struct {
18
+ resolved *appconfig.Resolved
19
+ manager *identity.Manager
20
+ remote *identity.RemoteClient
21
+ }
22
+
23
+ func NewService(resolved *appconfig.Resolved) (*Service, error) {
24
+ remote, err := identity.NewRemoteClient(resolved)
25
+ if err != nil {
26
+ return nil, err
27
+ }
28
+ return &Service{
29
+ resolved: resolved,
30
+ manager: identity.NewManager(resolved.Paths),
31
+ remote: remote,
32
+ }, nil
33
+ }
34
+
35
+ func (s *Service) Config() *appconfig.Resolved {
36
+ return s.resolved
37
+ }
38
+
39
+ func (s *Service) runtimeConfig() runtime.Resolved {
40
+ return runtime.Resolve(s.resolved)
41
+ }
42
+
43
+ func (s *Service) Send(ctx context.Context, request SendRequest) (*CommandResult, error) {
44
+ if strings.TrimSpace(request.Group) != "" {
45
+ return s.sendGroup(ctx, request)
46
+ }
47
+ if strings.TrimSpace(request.Target) == "" {
48
+ return nil, ErrTargetRequired
49
+ }
50
+ if strings.TrimSpace(request.Text) == "" {
51
+ return nil, ErrTextRequired
52
+ }
53
+ if request.SecureMode == "on" {
54
+ return nil, ErrSecureNotSupported
55
+ }
56
+ record, err := s.requireActiveIdentity(request.IdentityName)
57
+ if err != nil {
58
+ return nil, err
59
+ }
60
+ targetDID, targetHandle, err := s.resolveTarget(ctx, request.Target)
61
+ if err != nil {
62
+ return nil, err
63
+ }
64
+ transport, warnings, err := s.transportFor(record)
65
+ if err != nil {
66
+ return nil, err
67
+ }
68
+ request.Target = targetDID
69
+ result, err := transport.SendDirect(ctx, request)
70
+ if err != nil {
71
+ if fallback, fallbackWarnings, fallbackErr := s.httpFallbackSend(ctx, record, request, targetDID); fallbackErr == nil {
72
+ warnings = append(warnings, fallbackWarnings...)
73
+ return s.persistSendResult(ctx, record, targetDID, targetHandle, request, fallback, warnings)
74
+ }
75
+ return nil, err
76
+ }
77
+ return s.persistSendResult(ctx, record, targetDID, targetHandle, request, result, warnings)
78
+ }
79
+
80
+ func (s *Service) Inbox(ctx context.Context, request InboxRequest) (*CommandResult, error) {
81
+ if request.Limit <= 0 {
82
+ request.Limit = 20
83
+ }
84
+ if request.Scope == "" {
85
+ request.Scope = "all"
86
+ }
87
+ if request.Scope == "group" {
88
+ return s.groupInbox(ctx, request)
89
+ }
90
+ record, err := s.requireActiveIdentity(request.IdentityName)
91
+ if err != nil {
92
+ return nil, err
93
+ }
94
+ if request.Scope == "all" {
95
+ return s.allInbox(ctx, record, request)
96
+ }
97
+ peerDID := ""
98
+ peerHandle := ""
99
+ if strings.TrimSpace(request.With) != "" {
100
+ peerDID, peerHandle, err = s.resolveTarget(ctx, request.With)
101
+ if err != nil {
102
+ return nil, err
103
+ }
104
+ request.With = peerDID
105
+ }
106
+
107
+ mode := s.runtimeConfig()
108
+ warnings := make([]string, 0)
109
+ var raw map[string]any
110
+ switch mode.Mode {
111
+ case runtime.ModeWebSocket:
112
+ transport := NewWSProxyTransport(s.resolved, record.IdentityName)
113
+ raw, err = transport.GetInbox(ctx, request)
114
+ if err != nil {
115
+ cached, cacheErr := s.readInboxFromCache(ctx, record, peerDID, request.Limit, request.UnreadOnly)
116
+ if cacheErr == nil && len(cached) > 0 {
117
+ return &CommandResult{
118
+ Data: map[string]any{
119
+ "messages": cached,
120
+ "total": len(cached),
121
+ "source": "local_ws_cache_fallback",
122
+ "with": peerHandleOrDid(peerHandle, peerDID),
123
+ },
124
+ Summary: "Loaded inbox from local websocket cache",
125
+ Warnings: []string{err.Error()},
126
+ }, nil
127
+ }
128
+ httpTransport, httpWarnings, httpErr := s.httpTransport(record)
129
+ if httpErr != nil {
130
+ return nil, err
131
+ }
132
+ raw, err = httpTransport.GetInbox(ctx, request)
133
+ if err != nil {
134
+ return nil, err
135
+ }
136
+ warnings = append(warnings, "WebSocket transport unavailable; used HTTP fallback.")
137
+ warnings = append(warnings, httpWarnings...)
138
+ }
139
+ default:
140
+ httpTransport, httpWarnings, httpErr := s.httpTransport(record)
141
+ if httpErr != nil {
142
+ return nil, httpErr
143
+ }
144
+ raw, err = httpTransport.GetInbox(ctx, request)
145
+ if err != nil {
146
+ return nil, err
147
+ }
148
+ warnings = append(warnings, httpWarnings...)
149
+ }
150
+ messages, total := s.persistInboxMessages(ctx, record, raw)
151
+ if request.MarkRead && len(messages) > 0 {
152
+ messageIDs := collectMessageIDs(messages)
153
+ if len(messageIDs) > 0 {
154
+ if _, markErr := s.MarkRead(ctx, MarkReadRequest{IdentityName: record.IdentityName, MessageIDs: messageIDs}); markErr == nil {
155
+ raw["mark_read"] = true
156
+ }
157
+ }
158
+ }
159
+ return &CommandResult{
160
+ Data: map[string]any{
161
+ "messages": messages,
162
+ "total": total,
163
+ "source": sourceWithDefault(raw, mode.Mode),
164
+ "with": peerHandleOrDid(peerHandle, peerDID),
165
+ },
166
+ Summary: fmt.Sprintf("Loaded %d direct inbox messages", total),
167
+ Warnings: warnings,
168
+ }, nil
169
+ }
170
+
171
+ func (s *Service) History(ctx context.Context, request HistoryRequest) (*CommandResult, error) {
172
+ if strings.TrimSpace(request.With) == "" {
173
+ return nil, ErrTargetRequired
174
+ }
175
+ if request.Limit <= 0 {
176
+ request.Limit = 50
177
+ }
178
+ record, err := s.requireActiveIdentity(request.IdentityName)
179
+ if err != nil {
180
+ return nil, err
181
+ }
182
+ peerDID, peerHandle, err := s.resolveTarget(ctx, request.With)
183
+ if err != nil {
184
+ return nil, err
185
+ }
186
+ request.With = peerDID
187
+
188
+ mode := s.runtimeConfig()
189
+ warnings := make([]string, 0)
190
+ var raw map[string]any
191
+ switch mode.Mode {
192
+ case runtime.ModeWebSocket:
193
+ transport := NewWSProxyTransport(s.resolved, record.IdentityName)
194
+ raw, err = transport.GetHistory(ctx, request)
195
+ if err != nil {
196
+ cached, cacheErr := s.readHistoryFromCache(ctx, record, peerDID, request.Limit)
197
+ if cacheErr == nil && len(cached) > 0 {
198
+ return &CommandResult{
199
+ Data: map[string]any{
200
+ "messages": cached,
201
+ "total": len(cached),
202
+ "source": "local_ws_cache_fallback",
203
+ "with": peerHandleOrDid(peerHandle, peerDID),
204
+ },
205
+ Summary: "Loaded history from local websocket cache",
206
+ Warnings: []string{err.Error()},
207
+ }, nil
208
+ }
209
+ httpTransport, httpWarnings, httpErr := s.httpTransport(record)
210
+ if httpErr != nil {
211
+ return nil, err
212
+ }
213
+ raw, err = httpTransport.GetHistory(ctx, request)
214
+ if err != nil {
215
+ return nil, err
216
+ }
217
+ warnings = append(warnings, "WebSocket transport unavailable; used HTTP fallback.")
218
+ warnings = append(warnings, httpWarnings...)
219
+ }
220
+ default:
221
+ httpTransport, httpWarnings, httpErr := s.httpTransport(record)
222
+ if httpErr != nil {
223
+ return nil, httpErr
224
+ }
225
+ raw, err = httpTransport.GetHistory(ctx, request)
226
+ if err != nil {
227
+ return nil, err
228
+ }
229
+ warnings = append(warnings, httpWarnings...)
230
+ }
231
+ messages, total := s.persistHistoryMessages(ctx, record, peerDID, raw)
232
+ return &CommandResult{
233
+ Data: map[string]any{
234
+ "messages": messages,
235
+ "total": total,
236
+ "source": sourceWithDefault(raw, mode.Mode),
237
+ "with": peerHandleOrDid(peerHandle, peerDID),
238
+ },
239
+ Summary: fmt.Sprintf("Loaded %d direct history messages", total),
240
+ Warnings: warnings,
241
+ }, nil
242
+ }
243
+
244
+ func (s *Service) MarkRead(ctx context.Context, request MarkReadRequest) (*CommandResult, error) {
245
+ if len(request.MessageIDs) == 0 {
246
+ return nil, ErrMessageNotFound
247
+ }
248
+ record, err := s.requireActiveIdentity(request.IdentityName)
249
+ if err != nil {
250
+ return nil, err
251
+ }
252
+ db, openErr := store.Open(s.resolved.Paths)
253
+ if openErr == nil {
254
+ defer db.Close()
255
+ _ = store.EnsureSchema(ctx, db)
256
+ }
257
+ directIDs := make([]string, 0, len(request.MessageIDs))
258
+ groupIDs := make([]string, 0, len(request.MessageIDs))
259
+ if db != nil {
260
+ rows, queryErr := store.ListMessagesByIDs(ctx, db, record.DID, request.MessageIDs)
261
+ if queryErr == nil {
262
+ known := make(map[string]map[string]any, len(rows))
263
+ for _, row := range rows {
264
+ known[stringFromAny(row["msg_id"])] = row
265
+ }
266
+ for _, id := range request.MessageIDs {
267
+ row, ok := known[id]
268
+ if ok && (stringFromAny(row["group_did"]) != "" || stringFromAny(row["group_id"]) != "") {
269
+ groupIDs = append(groupIDs, id)
270
+ continue
271
+ }
272
+ directIDs = append(directIDs, id)
273
+ }
274
+ } else {
275
+ directIDs = append(directIDs, request.MessageIDs...)
276
+ }
277
+ } else {
278
+ directIDs = append(directIDs, request.MessageIDs...)
279
+ }
280
+ warnings := make([]string, 0)
281
+ updatedCount := 0
282
+ if len(directIDs) > 0 {
283
+ transport, transportWarnings, transportErr := s.transportFor(record)
284
+ if transportErr != nil {
285
+ return nil, transportErr
286
+ }
287
+ result, markErr := transport.MarkRead(ctx, MarkReadRequest{IdentityName: request.IdentityName, MessageIDs: directIDs})
288
+ if markErr != nil {
289
+ if httpTransport, httpWarnings, httpErr := s.httpTransport(record); httpErr == nil {
290
+ result, markErr = httpTransport.MarkRead(ctx, MarkReadRequest{IdentityName: request.IdentityName, MessageIDs: directIDs})
291
+ if markErr == nil {
292
+ transportWarnings = append(transportWarnings, "WebSocket transport unavailable; used HTTP fallback.")
293
+ transportWarnings = append(transportWarnings, httpWarnings...)
294
+ }
295
+ }
296
+ }
297
+ if markErr != nil {
298
+ return nil, markErr
299
+ }
300
+ warnings = append(warnings, transportWarnings...)
301
+ updatedCount += intValueFromAny(result["updated_count"], len(directIDs))
302
+ }
303
+ if db != nil {
304
+ localIDs := append(append([]string{}, directIDs...), groupIDs...)
305
+ if len(localIDs) > 0 {
306
+ if count, markErr := store.MarkMessagesRead(ctx, db, record.DID, localIDs); markErr != nil {
307
+ warnings = append(warnings, fmt.Sprintf("Failed to mark local messages read: %v", markErr))
308
+ } else if updatedCount == 0 {
309
+ updatedCount = int(count)
310
+ } else {
311
+ updatedCount += len(groupIDs)
312
+ }
313
+ }
314
+ }
315
+ return &CommandResult{
316
+ Data: map[string]any{
317
+ "action": "mark_read",
318
+ "updated_count": updatedCount,
319
+ "message_ids": request.MessageIDs,
320
+ },
321
+ Summary: fmt.Sprintf("Marked %d messages as read", updatedCount),
322
+ Warnings: compactWarnings(warnings),
323
+ }, nil
324
+ }
325
+
326
+ func (s *Service) groupInbox(ctx context.Context, request InboxRequest) (*CommandResult, error) {
327
+ record, err := s.requireActiveIdentity(request.IdentityName)
328
+ if err != nil {
329
+ return nil, err
330
+ }
331
+ groupMessages, err := s.readGroupInboxFromCache(ctx, record, request.Group, request.Limit, request.UnreadOnly)
332
+ if err != nil {
333
+ return nil, err
334
+ }
335
+ if request.MarkRead {
336
+ ids := collectMessageIDs(groupMessages)
337
+ if len(ids) > 0 {
338
+ _, _ = s.MarkRead(ctx, MarkReadRequest{IdentityName: request.IdentityName, MessageIDs: ids})
339
+ for _, message := range groupMessages {
340
+ message["is_read"] = true
341
+ }
342
+ }
343
+ }
344
+ return &CommandResult{
345
+ Data: map[string]any{
346
+ "messages": groupMessages,
347
+ "total": len(groupMessages),
348
+ "source": "local_group_cache",
349
+ "group": request.Group,
350
+ },
351
+ Summary: fmt.Sprintf("Loaded %d group inbox messages", len(groupMessages)),
352
+ Warnings: nil,
353
+ }, nil
354
+ }
355
+
356
+ func (s *Service) allInbox(ctx context.Context, record *identity.StoredIdentity, request InboxRequest) (*CommandResult, error) {
357
+ directRequest := request
358
+ directRequest.Scope = "direct"
359
+ directRequest.Group = ""
360
+ directResult, err := s.Inbox(ctx, directRequest)
361
+ if err != nil {
362
+ return nil, err
363
+ }
364
+ groupMessages, groupErr := s.readAllLocalGroupInbox(ctx, record, request.Limit, request.UnreadOnly)
365
+ warnings := make([]string, 0)
366
+ if groupErr != nil {
367
+ warnings = append(warnings, fmt.Sprintf("Failed to read local group inbox cache: %v", groupErr))
368
+ }
369
+ directMessages := messagesFromResult(directResult.Data["messages"])
370
+ merged := mergeInboxMessages(request.Limit, directMessages, groupMessages)
371
+ if request.MarkRead {
372
+ ids := collectMessageIDs(merged)
373
+ if len(ids) > 0 {
374
+ _, _ = s.MarkRead(ctx, MarkReadRequest{IdentityName: request.IdentityName, MessageIDs: ids})
375
+ for _, message := range merged {
376
+ message["is_read"] = true
377
+ }
378
+ }
379
+ }
380
+ warnings = append(warnings, directResult.Warnings...)
381
+ return &CommandResult{
382
+ Data: map[string]any{
383
+ "messages": merged,
384
+ "total": len(merged),
385
+ "source": "remote_http+local_group_cache",
386
+ },
387
+ Summary: fmt.Sprintf("Loaded %d inbox messages", len(merged)),
388
+ Warnings: compactWarnings(warnings),
389
+ }, nil
390
+ }
391
+
392
+ func (s *Service) transportFor(record *identity.StoredIdentity) (Transport, []string, error) {
393
+ mode := s.runtimeConfig()
394
+ if mode.Mode == runtime.ModeWebSocket {
395
+ return NewWSProxyTransport(s.resolved, record.IdentityName), nil, nil
396
+ }
397
+ transport, warnings, err := s.httpTransport(record)
398
+ return transport, warnings, err
399
+ }
400
+
401
+ func (s *Service) httpTransport(record *identity.StoredIdentity) (*HTTPTransport, []string, error) {
402
+ auth, err := newAuthContext(record, s.manager)
403
+ if err != nil {
404
+ return nil, nil, err
405
+ }
406
+ return NewHTTPTransport(s.resolved, auth, http.DefaultClient), nil, nil
407
+ }
408
+
409
+ func (s *Service) httpFallbackSend(ctx context.Context, record *identity.StoredIdentity, request SendRequest, targetDID string) (*directSendResult, []string, error) {
410
+ transport, warnings, err := s.httpTransport(record)
411
+ if err != nil {
412
+ return nil, nil, err
413
+ }
414
+ request.Target = targetDID
415
+ result, err := transport.SendDirect(ctx, request)
416
+ return result, warnings, err
417
+ }
418
+
419
+ func (s *Service) requireActiveIdentity(requested string) (*identity.StoredIdentity, error) {
420
+ identityName := strings.TrimSpace(requested)
421
+ if identityName == "" {
422
+ identityName = strings.TrimSpace(s.resolved.ActiveIdentity)
423
+ }
424
+ if identityName == "" {
425
+ current, err := s.manager.Current()
426
+ if err != nil {
427
+ return nil, err
428
+ }
429
+ identityName = current.IdentityName
430
+ s.resolved.ActiveIdentity = identityName
431
+ }
432
+ record, err := s.manager.Load(identityName)
433
+ if err != nil {
434
+ return nil, err
435
+ }
436
+ userState := identity.EvaluateStoredIdentityUserState(record)
437
+ if !userState.ReadyForMessaging {
438
+ return nil, identity.UserRegistrationError(record.IdentityName, userState)
439
+ }
440
+ return record, nil
441
+ }
442
+
443
+ func (s *Service) resolveTarget(ctx context.Context, target string) (string, string, error) {
444
+ target = strings.TrimSpace(target)
445
+ if strings.HasPrefix(target, "did:") {
446
+ return target, "", nil
447
+ }
448
+ var lookup map[string]any
449
+ if err := s.remote.RPCCall(ctx, "/user-service/handle/rpc", "lookup", map[string]any{"handle": target}, "", &lookup); err != nil {
450
+ return "", "", err
451
+ }
452
+ did := stringFromAny(lookup["did"])
453
+ if did == "" {
454
+ return "", "", fmt.Errorf("%w: %s", ErrTargetRequired, target)
455
+ }
456
+ return did, target, nil
457
+ }
458
+
459
+ func (s *Service) persistSendResult(ctx context.Context, record *identity.StoredIdentity, targetDID string, targetHandle string, request SendRequest, result *directSendResult, warnings []string) (*CommandResult, error) {
460
+ db, err := store.Open(s.resolved.Paths)
461
+ if err != nil {
462
+ return nil, err
463
+ }
464
+ defer db.Close()
465
+ if err := store.EnsureSchema(ctx, db); err != nil {
466
+ return nil, err
467
+ }
468
+ if err := store.StoreMessage(ctx, db, store.MessageRecord{
469
+ MsgID: result.MessageID,
470
+ OwnerDID: record.DID,
471
+ ThreadID: store.MakeThreadID(record.DID, targetDID, ""),
472
+ Direction: 1,
473
+ SenderDID: record.DID,
474
+ ReceiverDID: targetDID,
475
+ ContentType: contentTypeForMessageType(request.MessageType),
476
+ Content: request.Text,
477
+ ServerSeq: nil,
478
+ SentAt: result.AcceptedAt,
479
+ IsRead: true,
480
+ Metadata: metadataString(map[string]any{"delivery_state": result.DeliveryState, "operation_id": result.OperationID, "target_handle": targetHandle}),
481
+ CredentialName: record.IdentityName,
482
+ }); err != nil {
483
+ warnings = append(warnings, fmt.Sprintf("Failed to persist local message: %v", err))
484
+ }
485
+ return &CommandResult{
486
+ Data: map[string]any{
487
+ "action": "send_message",
488
+ "target": map[string]any{
489
+ "did": targetDID,
490
+ "handle": targetHandle,
491
+ "kind": "direct",
492
+ },
493
+ "message": map[string]any{
494
+ "id": result.MessageID,
495
+ "type": request.MessageType,
496
+ "secure": false,
497
+ "sent_at": result.AcceptedAt,
498
+ },
499
+ "delivery": result,
500
+ },
501
+ Summary: fmt.Sprintf("Sent a direct %s message", request.MessageType),
502
+ Warnings: warnings,
503
+ }, nil
504
+ }
505
+
506
+ func (s *Service) persistInboxMessages(ctx context.Context, record *identity.StoredIdentity, raw map[string]any) ([]map[string]any, int) {
507
+ db, err := store.Open(s.resolved.Paths)
508
+ if err != nil {
509
+ return nil, 0
510
+ }
511
+ defer db.Close()
512
+ _ = store.EnsureSchema(ctx, db)
513
+ messages := messagesFromResult(raw["messages"])
514
+ storable := make([]store.MessageRecord, 0, len(messages))
515
+ for _, message := range messages {
516
+ msgID := stringFromAny(message["id"])
517
+ if msgID == "" {
518
+ continue
519
+ }
520
+ senderDID := stringFromAny(message["sender_did"])
521
+ receiverDID := stringFromAny(message["receiver_did"])
522
+ peerDID := senderDID
523
+ if peerDID == record.DID {
524
+ peerDID = receiverDID
525
+ }
526
+ storable = append(storable, store.MessageRecord{
527
+ MsgID: msgID,
528
+ OwnerDID: record.DID,
529
+ ThreadID: store.MakeThreadID(record.DID, peerDID, ""),
530
+ Direction: 0,
531
+ SenderDID: senderDID,
532
+ ReceiverDID: receiverDID,
533
+ ContentType: stringFromAny(message["content_type"]),
534
+ Content: stringFromAny(message["content"]),
535
+ ServerSeq: int64PtrFromAny(message["server_seq"]),
536
+ SentAt: stringFromAny(message["sent_at"]),
537
+ IsRead: boolFromAny(message["is_read"]),
538
+ SenderName: stringFromAny(message["sender_name"]),
539
+ Metadata: metadataString(message),
540
+ CredentialName: record.IdentityName,
541
+ })
542
+ }
543
+ _ = store.StoreMessagesBatch(ctx, db, storable)
544
+ return messages, intValueFromAny(raw["total"], len(messages))
545
+ }
546
+
547
+ func (s *Service) persistHistoryMessages(ctx context.Context, record *identity.StoredIdentity, peerDID string, raw map[string]any) ([]map[string]any, int) {
548
+ db, err := store.Open(s.resolved.Paths)
549
+ if err != nil {
550
+ return nil, 0
551
+ }
552
+ defer db.Close()
553
+ _ = store.EnsureSchema(ctx, db)
554
+ messages := messagesFromResult(raw["messages"])
555
+ storable := make([]store.MessageRecord, 0, len(messages))
556
+ for _, message := range messages {
557
+ msgID := stringFromAny(message["id"])
558
+ if msgID == "" {
559
+ continue
560
+ }
561
+ senderDID := stringFromAny(message["sender_did"])
562
+ receiverDID := stringFromAny(message["receiver_did"])
563
+ direction := 0
564
+ if senderDID == record.DID {
565
+ direction = 1
566
+ }
567
+ storable = append(storable, store.MessageRecord{
568
+ MsgID: msgID,
569
+ OwnerDID: record.DID,
570
+ ThreadID: store.MakeThreadID(record.DID, peerDID, ""),
571
+ Direction: direction,
572
+ SenderDID: senderDID,
573
+ ReceiverDID: receiverDID,
574
+ ContentType: stringFromAny(message["content_type"]),
575
+ Content: stringFromAny(message["content"]),
576
+ ServerSeq: int64PtrFromAny(message["server_seq"]),
577
+ SentAt: stringFromAny(message["sent_at"]),
578
+ IsRead: boolFromAny(message["is_read"]),
579
+ SenderName: stringFromAny(message["sender_name"]),
580
+ Metadata: metadataString(message),
581
+ CredentialName: record.IdentityName,
582
+ })
583
+ }
584
+ _ = store.StoreMessagesBatch(ctx, db, storable)
585
+ return messages, intValueFromAny(raw["total"], len(messages))
586
+ }
587
+
588
+ func (s *Service) readInboxFromCache(ctx context.Context, record *identity.StoredIdentity, peerDID string, limit int, unreadOnly bool) ([]map[string]any, error) {
589
+ db, err := store.Open(s.resolved.Paths)
590
+ if err != nil {
591
+ return nil, err
592
+ }
593
+ defer db.Close()
594
+ if err := store.EnsureSchema(ctx, db); err != nil {
595
+ return nil, err
596
+ }
597
+ return store.ListInboxMessages(ctx, db, record.DID, limit, peerDID, unreadOnly)
598
+ }
599
+
600
+ func (s *Service) readHistoryFromCache(ctx context.Context, record *identity.StoredIdentity, peerDID string, limit int) ([]map[string]any, error) {
601
+ db, err := store.Open(s.resolved.Paths)
602
+ if err != nil {
603
+ return nil, err
604
+ }
605
+ defer db.Close()
606
+ if err := store.EnsureSchema(ctx, db); err != nil {
607
+ return nil, err
608
+ }
609
+ threadID := store.MakeThreadID(record.DID, peerDID, "")
610
+ return store.ListThreadMessages(ctx, db, record.DID, threadID, limit)
611
+ }
612
+
613
+ func messagesFromResult(value any) []map[string]any {
614
+ items, ok := value.([]any)
615
+ if !ok {
616
+ if typed, ok := value.([]map[string]any); ok {
617
+ return typed
618
+ }
619
+ return nil
620
+ }
621
+ result := make([]map[string]any, 0, len(items))
622
+ for _, item := range items {
623
+ if message, ok := item.(map[string]any); ok {
624
+ result = append(result, message)
625
+ }
626
+ }
627
+ return result
628
+ }
629
+
630
+ func collectMessageIDs(messages []map[string]any) []string {
631
+ ids := make([]string, 0, len(messages))
632
+ for _, message := range messages {
633
+ if id := stringFromAny(message["id"]); id != "" {
634
+ ids = append(ids, id)
635
+ }
636
+ }
637
+ return ids
638
+ }
639
+
640
+ func contentTypeForMessageType(messageType string) string {
641
+ switch strings.ToLower(strings.TrimSpace(messageType)) {
642
+ case "", "text":
643
+ return "text/plain"
644
+ case "event":
645
+ return "application/json"
646
+ default:
647
+ return "text/plain"
648
+ }
649
+ }
650
+
651
+ func nowRFC3339() string {
652
+ return time.Now().UTC().Format(time.RFC3339)
653
+ }
654
+
655
+ func generateOperationID() string {
656
+ return strings.ReplaceAll(time.Now().UTC().Format("20060102T150405.000000000"), ".", "")
657
+ }
658
+
659
+ func metadataString(value any) string {
660
+ if value == nil {
661
+ return ""
662
+ }
663
+ raw, err := json.Marshal(value)
664
+ if err != nil {
665
+ return ""
666
+ }
667
+ return string(raw)
668
+ }
669
+
670
+ func peerHandleOrDid(handle string, did string) string {
671
+ if handle != "" {
672
+ return handle
673
+ }
674
+ return did
675
+ }
676
+
677
+ func sourceWithDefault(result map[string]any, mode string) string {
678
+ if source := stringFromAny(result["source"]); source != "" {
679
+ return source
680
+ }
681
+ if mode == runtime.ModeWebSocket {
682
+ return "local_ws_cache"
683
+ }
684
+ return "remote_http"
685
+ }
686
+
687
+ func decodeMapInto(source map[string]any, destination any) {
688
+ if source == nil || destination == nil {
689
+ return
690
+ }
691
+ raw, err := json.Marshal(source)
692
+ if err != nil {
693
+ return
694
+ }
695
+ _ = json.Unmarshal(raw, destination)
696
+ }