work-ally 0.2.0-alpha.1

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 (172) hide show
  1. package/AGENTS.md +110 -0
  2. package/DASHBOARD.md +160 -0
  3. package/PRODUCT.md +113 -0
  4. package/README.md +403 -0
  5. package/ally.sh +171 -0
  6. package/bridge/src/approval-rules.ts +360 -0
  7. package/bridge/src/channel-delivery.ts +207 -0
  8. package/bridge/src/channel-types.ts +22 -0
  9. package/bridge/src/channels/fake/adapter.ts +31 -0
  10. package/bridge/src/channels/feishu/adapter.ts +411 -0
  11. package/bridge/src/channels/feishu/approvals.ts +6 -0
  12. package/bridge/src/channels/feishu/formatter.ts +276 -0
  13. package/bridge/src/channels/feishu/normalize.ts +368 -0
  14. package/bridge/src/codex-config.ts +52 -0
  15. package/bridge/src/config.ts +240 -0
  16. package/bridge/src/fake-runtime-client.ts +505 -0
  17. package/bridge/src/handoff-service.ts +494 -0
  18. package/bridge/src/logger.ts +194 -0
  19. package/bridge/src/memory-digest.ts +186 -0
  20. package/bridge/src/receiver-approval-autonomy.ts +158 -0
  21. package/bridge/src/receiver-control-core.ts +140 -0
  22. package/bridge/src/receiver-control-work-session.ts +218 -0
  23. package/bridge/src/receiver-control.ts +83 -0
  24. package/bridge/src/receiver-delivery.ts +136 -0
  25. package/bridge/src/receiver-helpers.ts +96 -0
  26. package/bridge/src/receiver-human-gate.ts +333 -0
  27. package/bridge/src/receiver-inbound-preflight.ts +162 -0
  28. package/bridge/src/receiver-recovery.ts +236 -0
  29. package/bridge/src/receiver-runtime-callbacks.ts +367 -0
  30. package/bridge/src/receiver-runtime-policy.ts +132 -0
  31. package/bridge/src/receiver-runtime-state.ts +124 -0
  32. package/bridge/src/receiver-support-actions.ts +189 -0
  33. package/bridge/src/receiver-thread-start.ts +57 -0
  34. package/bridge/src/receiver-turn-coordination.ts +94 -0
  35. package/bridge/src/receiver-turn-execution.ts +257 -0
  36. package/bridge/src/receiver-turn-failure.ts +143 -0
  37. package/bridge/src/receiver-turn-result.ts +185 -0
  38. package/bridge/src/receiver-turn-steer.ts +70 -0
  39. package/bridge/src/receiver-work-session.ts +76 -0
  40. package/bridge/src/receiver.ts +329 -0
  41. package/bridge/src/router.ts +62 -0
  42. package/bridge/src/runtime-client-agent-messages.ts +150 -0
  43. package/bridge/src/runtime-client-message-dispatch.ts +176 -0
  44. package/bridge/src/runtime-client-protocol.ts +411 -0
  45. package/bridge/src/runtime-client-request-ops.ts +56 -0
  46. package/bridge/src/runtime-client-run-turn.ts +158 -0
  47. package/bridge/src/runtime-client-thread-ops.ts +270 -0
  48. package/bridge/src/runtime-client-transport.ts +309 -0
  49. package/bridge/src/runtime-client-turn-poll.ts +224 -0
  50. package/bridge/src/runtime-client-turn-read.ts +185 -0
  51. package/bridge/src/runtime-client-turn-state.ts +105 -0
  52. package/bridge/src/runtime-client.ts +344 -0
  53. package/bridge/src/runtime-user-input.ts +403 -0
  54. package/bridge/src/scheduler.ts +239 -0
  55. package/bridge/src/server-handoff-command.ts +364 -0
  56. package/bridge/src/server-main.ts +80 -0
  57. package/bridge/src/server-routine-command.ts +60 -0
  58. package/bridge/src/server-routine-execution.ts +222 -0
  59. package/bridge/src/server-runtime-app-support.ts +107 -0
  60. package/bridge/src/server-runtime-app.ts +238 -0
  61. package/bridge/src/server-thread-sync-command.ts +63 -0
  62. package/bridge/src/server.ts +17 -0
  63. package/bridge/src/session-store-delivery.ts +220 -0
  64. package/bridge/src/session-store-human-gate.ts +380 -0
  65. package/bridge/src/session-store-inbound-acceptance.ts +66 -0
  66. package/bridge/src/session-store-meta.ts +134 -0
  67. package/bridge/src/session-store-turn-ledger.ts +272 -0
  68. package/bridge/src/session-store.ts +380 -0
  69. package/bridge/src/system-notify.ts +220 -0
  70. package/bridge/src/thread-sync.ts +200 -0
  71. package/bridge/src/translator.ts +494 -0
  72. package/bridge/src/types.ts +289 -0
  73. package/bridge/src/utils.ts +104 -0
  74. package/bridge/src/work-session-store.ts +471 -0
  75. package/docs/.gitkeep +0 -0
  76. package/docs/architecture/codex-feishu-bridge-proposal.md +2742 -0
  77. package/docs/completed/FEATURE-feishu-markdown-and-reply-support.md +327 -0
  78. package/docs/completed/README.md +21 -0
  79. package/docs/completed/SPEC-approval-autonomy-and-safe-defaults.md +205 -0
  80. package/docs/completed/SPEC-approval-batch-and-strict-reply-shortcuts.md +153 -0
  81. package/docs/completed/SPEC-conversation-noise-reduction-and-busy-input-gate.md +538 -0
  82. package/docs/completed/SPEC-engineering-sop-skillization.md +190 -0
  83. package/docs/completed/SPEC-faithful-bridge-core-thinning-v2.md +376 -0
  84. package/docs/completed/SPEC-faithful-bridge-core-thinning.md +1071 -0
  85. package/docs/completed/SPEC-group-chat-sender-identity.md +301 -0
  86. package/docs/completed/SPEC-middleware-exception-visibility.md +227 -0
  87. package/docs/completed/SPEC-nightly-memory-digest-visibility.md +121 -0
  88. package/docs/completed/SPEC-project-group-chat-human-centered-conversation-mapping.md +326 -0
  89. package/docs/completed/SPEC-remove-cli-persona-bootstrap.md +201 -0
  90. package/docs/developer-workflow.md +49 -0
  91. package/docs/implementation/SPEC-codex-same-machine-session-handoff-implementation.md +239 -0
  92. package/docs/implementation/test-coverage-map.md +363 -0
  93. package/docs/implementation/work-ally-implementation-guide.md +790 -0
  94. package/docs/issues/README.md +10 -0
  95. package/docs/issues/pending/ANALYSIS-ally-premature-recovery-notice-and-task-state-semantics-2026-03-18.md +295 -0
  96. package/docs/issues/resolved/ANALYSIS-approval-waiting-visible-but-approval-artifact-missing-2026-03-16.md +466 -0
  97. package/docs/issues/resolved/ANALYSIS-blocking-state-visible-without-user-actionable-artifact-2026-03-16.md +261 -0
  98. package/docs/issues/resolved/ANALYSIS-codex-app-server-transport-disconnect-semantics-2026-03-14.md +606 -0
  99. package/docs/issues/resolved/ANALYSIS-premature-terminalization-on-fresh-thread-poll-and-object-error-leak-2026-03-16.md +348 -0
  100. package/docs/issues/resolved/ANALYSIS-runtime-turn-delivery-and-recovery-2026-03-14.md +603 -0
  101. package/docs/issues/resolved/ANALYSIS-self-test-gap-approval-waiting-visible-but-approval-artifact-missing-2026-03-16.md +166 -0
  102. package/docs/issues/resolved/ANALYSIS-self-test-gap-blocking-state-visible-without-user-actionable-artifact-2026-03-16.md +186 -0
  103. package/docs/issues/resolved/ANALYSIS-self-test-gap-premature-terminalization-on-fresh-thread-poll-and-object-error-leak-2026-03-16.md +166 -0
  104. package/docs/issues/resolved/REPORT-ally-runtime-turn-delivery-3b42fb8-2026-03-15.md +373 -0
  105. package/docs/manual-acceptance.md +127 -0
  106. package/docs/ops-runbook.md +44 -0
  107. package/docs/planning/FEATURE-memory-system.md +748 -0
  108. package/docs/planning/SPEC-active-turn-steer-and-context-compaction-visibility.md +269 -0
  109. package/docs/planning/SPEC-approval-rules-inheritance-and-local-validation-lane.md +450 -0
  110. package/docs/planning/SPEC-assistant-persona-bootstrap.md +199 -0
  111. package/docs/planning/SPEC-assistant-rename.md +610 -0
  112. package/docs/planning/SPEC-bridge-app-server-protocol-alignment.md +667 -0
  113. package/docs/planning/SPEC-claude-runtime-host-for-work-ally.md +434 -0
  114. package/docs/planning/SPEC-cli-feishu-codex-session-unification.md +236 -0
  115. package/docs/planning/SPEC-codex-same-machine-session-handoff.md +873 -0
  116. package/docs/planning/SPEC-feishu-reaction-shortcuts.md +282 -0
  117. package/docs/planning/SPEC-local-stable-release-boundary.md +166 -0
  118. package/docs/planning/SPEC-managed-thread-entry-and-surface-mobility.md +862 -0
  119. package/docs/planning/SPEC-minimal-bridge-semantics-and-user-visible-surface.md +362 -0
  120. package/docs/planning/SPEC-npm-alpha-distribution-and-install-first-release.md +222 -0
  121. package/docs/planning/SPEC-remove-websocket-runtime-transport.md +364 -0
  122. package/docs/planning/SPEC-runtime-abstraction-phase-1.md +424 -0
  123. package/docs/planning/SPEC-runtime-connection-and-turn-recovery-semantics.md +274 -0
  124. package/docs/planning/SPEC-session-presence-and-state-visibility.md +397 -0
  125. package/docs/planning/SPEC-skill-first-capability-packaging.md +338 -0
  126. package/docs/planning/SPEC-stable-archive-contract.md +456 -0
  127. package/docs/planning/SPEC-supervised-start-boundary.md +127 -0
  128. package/docs/planning/SPEC-user-barrier-reduction-and-activation.md +832 -0
  129. package/docs/planning/ally-next.md +1278 -0
  130. package/docs/planning/assistant-workbench-spec.md +725 -0
  131. package/docs/planning/product-workbench.md +283 -0
  132. package/docs/product-onboarding.md +227 -0
  133. package/docs/product-spec-standard.md +528 -0
  134. package/docs/troubleshooting.md +45 -0
  135. package/docs/user-quickstart.md +46 -0
  136. package/internal/dispatch.sh +95 -0
  137. package/internal/lib/common.sh +1450 -0
  138. package/internal/modules/assistant/manage.sh +1312 -0
  139. package/internal/modules/bootstrap/setup.sh +144 -0
  140. package/internal/modules/config/init-env.sh +10 -0
  141. package/internal/modules/global/manage.sh +154 -0
  142. package/internal/modules/handoff/manage.sh +54 -0
  143. package/internal/modules/mcp/manage.sh +83 -0
  144. package/internal/modules/ops/logs.sh +76 -0
  145. package/internal/modules/routines/manage.sh +55 -0
  146. package/internal/modules/runtime/assistant-autosave.sh +26 -0
  147. package/internal/modules/runtime/restart.sh +6 -0
  148. package/internal/modules/runtime/start.sh +283 -0
  149. package/internal/modules/runtime/status.sh +194 -0
  150. package/internal/modules/runtime/stop.sh +55 -0
  151. package/internal/modules/runtime/supervisor.sh +216 -0
  152. package/internal/modules/runtime/update.sh +26 -0
  153. package/package.json +41 -0
  154. package/runtime/config/.gitkeep +0 -0
  155. package/runtime/host/.gitkeep +0 -0
  156. package/runtime/host/healthcheck-codex-app-server.ts +22 -0
  157. package/runtime/host/ping-pong-codex-app-server.ts +66 -0
  158. package/runtime/host/probe-codex-app-server.ts +115 -0
  159. package/skills/archive-reader/SKILL.md +9 -0
  160. package/skills/feishu-production-debug/SKILL.md +37 -0
  161. package/skills/feishu-production-debug/references/feishu-debug-order.md +49 -0
  162. package/skills/feishu-production-debug/references/platform-permission-baseline.md +23 -0
  163. package/skills/issue-to-spec-triage/SKILL.md +44 -0
  164. package/skills/issue-to-spec-triage/references/triage-rules.md +66 -0
  165. package/skills/memory-digest/SKILL.md +9 -0
  166. package/skills/post-implementation-closure/SKILL.md +39 -0
  167. package/skills/post-implementation-closure/references/closure-checklist.md +45 -0
  168. package/skills/post-implementation-closure/references/doc-drift-map.md +49 -0
  169. package/skills/product-spec/SKILL.md +244 -0
  170. package/templates/env.example +5 -0
  171. package/templates/routines/nightly-memory-digest.yaml +10 -0
  172. package/templates/workspace/AGENTS.md +26 -0
@@ -0,0 +1,411 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import lark from '@larksuiteoapi/node-sdk';
3
+ import type { ChannelAdapter, InboundHandler, OutboundEnvelope } from '../../channel-types.ts';
4
+ import type { ConnectionLifecycleEvent, InboundMessage } from '../../types.ts';
5
+ import { renderFeishuMessages, renderFeishuPlainText, toInteractiveCard } from './formatter.ts';
6
+ import { buildApprovalCardText } from './approvals.ts';
7
+ import { extractFeishuMessageId, extractTextFromPayload, normalizeFeishuEvent, summarizeFeishuEvent } from './normalize.ts';
8
+ import { logDebug, logError, logInfo, logWarn } from '../../logger.ts';
9
+ import { normalizeChannelDeliveryError } from '../../channel-delivery.ts';
10
+
11
+ export interface FeishuAdapterOptions {
12
+ appId: string;
13
+ appSecret: string;
14
+ reactEmoji: string;
15
+ allowedUserIds: string[];
16
+ }
17
+
18
+ interface FeishuSdkClient {
19
+ im: {
20
+ v1: {
21
+ messageReaction: {
22
+ create(payload: any): Promise<unknown>;
23
+ };
24
+ message: {
25
+ create(payload: any): Promise<unknown>;
26
+ get?(payload: any): Promise<any>;
27
+ };
28
+ };
29
+ };
30
+ }
31
+
32
+ interface FeishuWsClient {
33
+ start(options: any): void;
34
+ close?(params?: { force?: boolean }): void;
35
+ wsConfig?: {
36
+ getWSInstance?: () => {
37
+ terminate?: () => void;
38
+ on?: (event: string, handler: (...args: any[]) => void) => void;
39
+ off?: (event: string, handler: (...args: any[]) => void) => void;
40
+ removeListener?: (event: string, handler: (...args: any[]) => void) => void;
41
+ } | null;
42
+ };
43
+ }
44
+
45
+ interface FeishuAdapterDeps {
46
+ client?: FeishuSdkClient;
47
+ wsClientFactory?: (config: { appId: string; appSecret: string }) => FeishuWsClient;
48
+ }
49
+
50
+ export class FeishuAdapter implements ChannelAdapter {
51
+ private readonly inboundHandler: InboundHandler;
52
+ private readonly options: FeishuAdapterOptions;
53
+ private readonly client: FeishuSdkClient;
54
+ private readonly wsClientFactory: (config: { appId: string; appSecret: string }) => FeishuWsClient;
55
+ private wsClient: FeishuWsClient | null = null;
56
+ private readonly connectionEmitter = new EventEmitter();
57
+ private wsMonitorTimer: NodeJS.Timeout | null = null;
58
+ private observedWsInstance: any = null;
59
+ private sawOpenSinceStart = false;
60
+ private readonly handleWsOpen = () => {
61
+ const state = this.sawOpenSinceStart ? 'reconnected' : null;
62
+ this.sawOpenSinceStart = true;
63
+ if (!state) {
64
+ return;
65
+ }
66
+ this.emitConnectionEvent({ source: 'feishu', state, reason: null, attempt: null, nextRetryDelayMs: null });
67
+ };
68
+ private readonly handleWsClose = () => {
69
+ this.emitConnectionEvent({ source: 'feishu', state: 'disconnected', reason: 'connection closed', attempt: null, nextRetryDelayMs: null });
70
+ this.emitConnectionEvent({ source: 'feishu', state: 'reconnecting', reason: null, attempt: null, nextRetryDelayMs: null });
71
+ };
72
+ private readonly handleWsError = () => {
73
+ this.emitConnectionEvent({ source: 'feishu', state: 'disconnected', reason: 'connection error', attempt: null, nextRetryDelayMs: null });
74
+ };
75
+ private readonly dedupe = new Set<string>();
76
+ private readonly processing = new Set<string>();
77
+ private readonly reacted = new Set<string>();
78
+ private readonly debugEnabled = process.env.WORK_ALLY_FEISHU_DEBUG === '1';
79
+
80
+ constructor(inboundHandler: InboundHandler, options: FeishuAdapterOptions, deps: FeishuAdapterDeps = {}) {
81
+ this.inboundHandler = inboundHandler;
82
+ this.options = options;
83
+ this.client = deps.client ?? new lark.Client({
84
+ appId: options.appId,
85
+ appSecret: options.appSecret,
86
+ disableTokenCache: false,
87
+ }) as FeishuSdkClient;
88
+ this.wsClientFactory = deps.wsClientFactory ?? ((config) => new lark.WSClient(config) as FeishuWsClient);
89
+ }
90
+
91
+ private remember(set: Set<string>, value: string, limit = 2000): void {
92
+ set.add(value);
93
+ if (set.size > limit) {
94
+ const first = set.values().next().value;
95
+ if (first) {
96
+ set.delete(first);
97
+ }
98
+ }
99
+ }
100
+
101
+ private emitConnectionEvent(event: ConnectionLifecycleEvent): void {
102
+ this.connectionEmitter.emit('connectionLifecycle', event);
103
+ }
104
+
105
+ onConnectionEvent(listener: (event: ConnectionLifecycleEvent) => void): void {
106
+ this.connectionEmitter.on('connectionLifecycle', listener);
107
+ }
108
+
109
+ offConnectionEvent(listener: (event: ConnectionLifecycleEvent) => void): void {
110
+ this.connectionEmitter.off('connectionLifecycle', listener);
111
+ }
112
+
113
+ private detachObservedWsInstance(): void {
114
+ const instance = this.observedWsInstance;
115
+ if (!instance) {
116
+ return;
117
+ }
118
+ instance.off?.('open', this.handleWsOpen);
119
+ instance.removeListener?.('open', this.handleWsOpen);
120
+ instance.off?.('close', this.handleWsClose);
121
+ instance.removeListener?.('close', this.handleWsClose);
122
+ instance.off?.('error', this.handleWsError);
123
+ instance.removeListener?.('error', this.handleWsError);
124
+ this.observedWsInstance = null;
125
+ }
126
+
127
+ private attachWsLifecycle(instance: any): void {
128
+ if (!instance || this.observedWsInstance === instance) {
129
+ return;
130
+ }
131
+ this.detachObservedWsInstance();
132
+ instance.on?.('open', this.handleWsOpen);
133
+ instance.on?.('close', this.handleWsClose);
134
+ instance.on?.('error', this.handleWsError);
135
+ this.observedWsInstance = instance;
136
+ }
137
+
138
+ private startWsMonitor(): void {
139
+ this.stopWsMonitor();
140
+ const bindLatest = () => {
141
+ const instance = this.wsClient?.wsConfig?.getWSInstance?.() || null;
142
+ if (instance) {
143
+ this.attachWsLifecycle(instance);
144
+ }
145
+ };
146
+ bindLatest();
147
+ this.wsMonitorTimer = setInterval(bindLatest, 1000);
148
+ this.wsMonitorTimer.unref?.();
149
+ }
150
+
151
+ private stopWsMonitor(): void {
152
+ if (this.wsMonitorTimer) {
153
+ clearInterval(this.wsMonitorTimer);
154
+ this.wsMonitorTimer = null;
155
+ }
156
+ this.detachObservedWsInstance();
157
+ }
158
+
159
+ private debug(message: string, meta?: unknown): void {
160
+ if (!this.debugEnabled) {
161
+ return;
162
+ }
163
+ if (meta === undefined) {
164
+ console.log(`[feishu-debug] ${message}`);
165
+ return;
166
+ }
167
+ console.log(`[feishu-debug] ${message}`, JSON.stringify(meta));
168
+ }
169
+
170
+ async start(): Promise<void> {
171
+ logInfo('feishu', 'ws_starting', {
172
+ allowlistSize: this.options.allowedUserIds.length,
173
+ });
174
+ const dispatcher = new lark.EventDispatcher({}).register({
175
+ 'im.message.receive_v1': async (data: any) => {
176
+ const summary = summarizeFeishuEvent(data);
177
+ this.debug('dispatcher received event', summary);
178
+ logInfo('feishu', 'event_received', summary);
179
+ await this.handleEvent(data);
180
+ },
181
+ });
182
+
183
+ this.wsClient = this.wsClientFactory({
184
+ appId: this.options.appId,
185
+ appSecret: this.options.appSecret,
186
+ });
187
+ this.sawOpenSinceStart = false;
188
+ this.wsClient.start({ eventDispatcher: dispatcher });
189
+ this.startWsMonitor();
190
+ logInfo('feishu', 'ws_started');
191
+ }
192
+
193
+ async stop(): Promise<void> {
194
+ logInfo('feishu', 'ws_stopping');
195
+ this.stopWsMonitor();
196
+ this.wsClient?.close?.({ force: true });
197
+ const instance = this.wsClient?.wsConfig?.getWSInstance?.();
198
+ instance?.terminate?.();
199
+ }
200
+
201
+ private async addReaction(messageId: string, emojiType = this.options.reactEmoji): Promise<void> {
202
+ const reactionKey = `${messageId}:${emojiType}`;
203
+ if (this.reacted.has(reactionKey)) {
204
+ return;
205
+ }
206
+ try {
207
+ await this.client.im.v1.messageReaction.create({
208
+ path: { message_id: messageId },
209
+ data: { reaction_type: { emoji_type: emojiType } },
210
+ });
211
+ this.remember(this.reacted, reactionKey);
212
+ } catch (error: any) {
213
+ const meta = {
214
+ messageId,
215
+ emojiType,
216
+ error: error?.message || String(error),
217
+ };
218
+ this.debug('reaction create failed', meta);
219
+ logWarn('feishu', 'reaction_failed', meta);
220
+ }
221
+ }
222
+
223
+ private resolveReceiveTarget(conversationRef: OutboundEnvelope['conversationRef']): { receiveId: string; receiveIdType: 'chat_id' | 'open_id' } {
224
+ const receiveId = String(conversationRef.conversationId || '').trim();
225
+ const chatType = String(conversationRef.chatType || '').trim().toLowerCase();
226
+ if ((chatType === 'p2p' || chatType === 'single' || chatType === 'dm') && receiveId.startsWith('ou_')) {
227
+ return { receiveId, receiveIdType: 'open_id' };
228
+ }
229
+ return { receiveId, receiveIdType: 'chat_id' };
230
+ }
231
+
232
+ private async sendMessage(receiveId: string, receiveIdType: 'chat_id' | 'open_id', messageType: string, content: string): Promise<void> {
233
+ try {
234
+ await this.client.im.v1.message.create({
235
+ params: { receive_id_type: receiveIdType },
236
+ data: {
237
+ receive_id: receiveId,
238
+ msg_type: messageType,
239
+ content,
240
+ },
241
+ });
242
+ logInfo('feishu', 'message_sent', {
243
+ receiveId,
244
+ receiveIdType,
245
+ messageType,
246
+ contentLength: content.length,
247
+ });
248
+ } catch (error) {
249
+ logError('feishu', 'message_send_failed', {
250
+ receiveId,
251
+ receiveIdType,
252
+ messageType,
253
+ error: error instanceof Error ? (error.stack || error.message) : String(error),
254
+ });
255
+ throw normalizeChannelDeliveryError('feishu', error);
256
+ }
257
+ }
258
+
259
+ private async fetchMessageSnapshot(messageId: string): Promise<{ text: string | null }> {
260
+ const getter = this.client.im.v1.message.get;
261
+ if (typeof getter !== 'function') {
262
+ return { text: null };
263
+ }
264
+
265
+ try {
266
+ const response = await getter({ path: { message_id: messageId } });
267
+ const rawData = response?.data;
268
+ const rawItems = Array.isArray(rawData?.items) ? rawData.items : null;
269
+ if (rawItems && rawItems.length > 0) {
270
+ const text = extractTextFromPayload(JSON.stringify({ items: rawItems }), 'merge_forward').trim() || null;
271
+ if (text) {
272
+ return { text };
273
+ }
274
+ }
275
+
276
+ const rawItem = rawItems?.[0] ?? rawData;
277
+ if (!rawItem) {
278
+ return { text: null };
279
+ }
280
+ const messageType = String(rawItem.msg_type || rawItem.message_type || 'text');
281
+ const rawContent = String(rawItem.body?.content || rawItem.content || '');
282
+ return { text: extractTextFromPayload(rawContent, messageType).trim() || null };
283
+ } catch (error) {
284
+ logWarn('feishu', 'reply_context_fetch_failed', {
285
+ messageId,
286
+ error: error instanceof Error ? (error.stack || error.message) : String(error),
287
+ });
288
+ return { text: null };
289
+ }
290
+ }
291
+
292
+ private async hydrateInboundTextIfNeeded(inbound: InboundMessage): Promise<InboundMessage> {
293
+ if (inbound.text.trim()) {
294
+ return inbound;
295
+ }
296
+
297
+ const snapshot = await this.fetchMessageSnapshot(inbound.messageId);
298
+ if (!snapshot.text) {
299
+ return inbound;
300
+ }
301
+
302
+ return {
303
+ ...inbound,
304
+ text: snapshot.text,
305
+ };
306
+ }
307
+
308
+ private async enrichReplyContext(inbound: InboundMessage): Promise<InboundMessage> {
309
+ if (!inbound.replyToMessageId) {
310
+ return inbound;
311
+ }
312
+ const snapshot = await this.fetchMessageSnapshot(inbound.replyToMessageId);
313
+ return {
314
+ ...inbound,
315
+ replyToText: snapshot.text,
316
+ };
317
+ }
318
+
319
+ async handleEvent(event: any): Promise<void> {
320
+ const summary = summarizeFeishuEvent(event);
321
+ const messageId = extractFeishuMessageId(event);
322
+ if (!messageId) {
323
+ this.debug('skipping event without messageId', summary);
324
+ logWarn('feishu', 'event_skipped_missing_message_id', summary);
325
+ return;
326
+ }
327
+ if (this.dedupe.has(messageId)) {
328
+ this.debug('skipping duplicate event', summary);
329
+ logDebug('feishu', 'event_deduped', summary);
330
+ return;
331
+ }
332
+ if (this.processing.has(messageId)) {
333
+ this.debug('skipping inflight duplicate event', summary);
334
+ logDebug('feishu', 'event_inflight_duplicate', summary);
335
+ return;
336
+ }
337
+ this.remember(this.processing, messageId);
338
+
339
+ try {
340
+ const inbound = normalizeFeishuEvent(event, this.options.allowedUserIds);
341
+ if (!inbound) {
342
+ const meta = {
343
+ ...summary,
344
+ allowlistSize: this.options.allowedUserIds.length,
345
+ };
346
+ this.debug('rejected inbound event', meta);
347
+ logWarn('feishu', 'event_rejected', meta);
348
+ this.remember(this.dedupe, messageId);
349
+ return;
350
+ }
351
+
352
+ const hydratedInbound = await this.hydrateInboundTextIfNeeded(inbound);
353
+ const enriched = await this.enrichReplyContext(hydratedInbound);
354
+ const acceptanceReason = enriched.conversationRef.chatType === 'group'
355
+ ? 'group_platform_filtered'
356
+ : 'direct_chat';
357
+ this.debug('accepted inbound event', {
358
+ ...summary,
359
+ reason: acceptanceReason,
360
+ replyToMessageId: enriched.replyToMessageId || null,
361
+ hasReplyToText: Boolean(enriched.replyToText),
362
+ });
363
+ logInfo('feishu', 'event_accepted', {
364
+ ...summary,
365
+ reason: acceptanceReason,
366
+ replyToMessageId: enriched.replyToMessageId || null,
367
+ hasReplyToText: Boolean(enriched.replyToText),
368
+ });
369
+ await this.addReaction(enriched.messageId);
370
+ await this.inboundHandler.handleInbound(enriched);
371
+ this.remember(this.dedupe, messageId);
372
+ } catch (error) {
373
+ logError('feishu', 'event_handle_failed', {
374
+ ...summary,
375
+ error: error instanceof Error ? (error.stack || error.message) : String(error),
376
+ });
377
+ throw error;
378
+ } finally {
379
+ this.processing.delete(messageId);
380
+ }
381
+ }
382
+
383
+ async send(outbound: OutboundEnvelope): Promise<void> {
384
+ const target = this.resolveReceiveTarget(outbound.conversationRef);
385
+ if (outbound.type === 'ack_received') {
386
+ logDebug('feishu', 'ack_reaction_requested', {
387
+ messageId: outbound.messageId,
388
+ emoji: outbound.emoji || this.options.reactEmoji,
389
+ });
390
+ await this.addReaction(outbound.messageId, outbound.emoji || this.options.reactEmoji);
391
+ return;
392
+ }
393
+
394
+ if (outbound.type === 'approval_requested') {
395
+ const cardText = String(outbound.text || '').trim() || buildApprovalCardText(outbound.approval);
396
+ await this.sendMessage(target.receiveId, target.receiveIdType, 'interactive', toInteractiveCard(cardText));
397
+ return;
398
+ }
399
+
400
+ if (outbound.type === 'user_input_requested') {
401
+ for (const rendered of renderFeishuPlainText(outbound.text)) {
402
+ await this.sendMessage(target.receiveId, target.receiveIdType, rendered.messageType, rendered.content);
403
+ }
404
+ return;
405
+ }
406
+
407
+ for (const rendered of renderFeishuMessages(outbound.text)) {
408
+ await this.sendMessage(target.receiveId, target.receiveIdType, rendered.messageType, rendered.content);
409
+ }
410
+ }
411
+ }
@@ -0,0 +1,6 @@
1
+ import type { ApprovalRequest } from '../../types.ts';
2
+ import { approvalMessage } from '../../translator.ts';
3
+
4
+ export function buildApprovalCardText(approval: ApprovalRequest): string {
5
+ return approvalMessage(approval);
6
+ }
@@ -0,0 +1,276 @@
1
+ export interface FeishuRenderedMessage {
2
+ messageType: 'post' | 'interactive' | 'text';
3
+ content: string;
4
+ }
5
+
6
+ const TEXT_MAX_LEN = 4000;
7
+ const TABLE_RE = /((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)/gm;
8
+ const HEADING_RE = /^(#{1,6})\s+(.+)$/gm;
9
+ const CODE_BLOCK_RE = /```(?:[a-zA-Z0-9_+-]+)?\n?([\s\S]*?)```/g;
10
+ const TABLE_DETECT_RE = /(?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+/m;
11
+ const SYSTEM_NOTICE_RE = /^\s*(?:\*\*)?【系统消息】(?:\*\*)?/;
12
+ const WORKING_NOTICE_RE = /^\s*(?:\*\*)?(?:【工作中】|⏳ 工作中)(?:\*\*)?/;
13
+
14
+ function buildLarkDiv(content: string): Record<string, unknown> {
15
+ return {
16
+ tag: 'div',
17
+ text: {
18
+ tag: 'lark_md',
19
+ content,
20
+ },
21
+ };
22
+ }
23
+
24
+ function buildLarkHr(): Record<string, unknown> {
25
+ return { tag: 'hr' };
26
+ }
27
+
28
+ function buildLarkNote(content: string): Record<string, unknown> {
29
+ return {
30
+ tag: 'note',
31
+ elements: [
32
+ {
33
+ tag: 'plain_text',
34
+ content,
35
+ },
36
+ ],
37
+ };
38
+ }
39
+
40
+ function stripNoticePrefix(content: string, pattern: RegExp): string {
41
+ return content.replace(pattern, '').trim();
42
+ }
43
+
44
+ function buildWorkingNoticeCard(content: string): string {
45
+ const body = stripNoticePrefix(content.trim(), WORKING_NOTICE_RE) || '正在处理中';
46
+ return buildCard([
47
+ buildLarkDiv('**工作进展**'),
48
+ buildLarkHr(),
49
+ buildLarkDiv(body),
50
+ buildLarkNote('状态提示,不是最终回复'),
51
+ ]);
52
+ }
53
+
54
+ function buildCard(elements: Record<string, unknown>[]): string {
55
+ return JSON.stringify({
56
+ config: { wide_screen_mode: true },
57
+ elements,
58
+ });
59
+ }
60
+
61
+ function buildPostMessage(content: string): FeishuRenderedMessage {
62
+ return {
63
+ messageType: 'post',
64
+ content: JSON.stringify({
65
+ zh_cn: {
66
+ content: [
67
+ [
68
+ {
69
+ tag: 'md',
70
+ text: content,
71
+ },
72
+ ],
73
+ ],
74
+ },
75
+ }),
76
+ };
77
+ }
78
+
79
+ function parseMarkdownTable(tableText: string): Record<string, unknown> | null {
80
+ const lines = tableText.trim().split('\n').map((line) => line.trim()).filter(Boolean);
81
+ if (lines.length < 3) {
82
+ return null;
83
+ }
84
+ const split = (line: string) => line.replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
85
+ const headers = split(lines[0]);
86
+ const rows = lines.slice(2).map(split);
87
+ return {
88
+ tag: 'table',
89
+ page_size: rows.length + 1,
90
+ columns: headers.map((header, index) => ({
91
+ tag: 'column',
92
+ name: `c${index}`,
93
+ display_name: header,
94
+ width: 'auto',
95
+ })),
96
+ rows: rows.map((row) => Object.fromEntries(headers.map((_, index) => [`c${index}`, row[index] ?? '']))),
97
+ };
98
+ }
99
+
100
+ function splitHeadings(content: string): Record<string, unknown>[] {
101
+ let protectedContent = content;
102
+ const codeBlocks: string[] = [];
103
+ for (const match of content.matchAll(CODE_BLOCK_RE)) {
104
+ const block = match[0];
105
+ const token = `\u0000CODE${codeBlocks.length}\u0000`;
106
+ codeBlocks.push(block);
107
+ protectedContent = protectedContent.replace(block, token);
108
+ }
109
+
110
+ const elements: Record<string, unknown>[] = [];
111
+ let lastIndex = 0;
112
+ for (const match of protectedContent.matchAll(HEADING_RE)) {
113
+ const start = match.index ?? 0;
114
+ const before = protectedContent.slice(lastIndex, start).trim();
115
+ if (before) {
116
+ elements.push(buildLarkDiv(before));
117
+ }
118
+ const headingText = match[2].trim();
119
+ elements.push(buildLarkDiv(`**${headingText}**`));
120
+ lastIndex = start + match[0].length;
121
+ }
122
+ const rest = protectedContent.slice(lastIndex).trim();
123
+ if (rest) {
124
+ elements.push(buildLarkDiv(rest));
125
+ }
126
+
127
+ for (const element of elements) {
128
+ const text = (element.text as { content?: string } | undefined)?.content;
129
+ if (!text) {
130
+ continue;
131
+ }
132
+ let restored = text;
133
+ codeBlocks.forEach((block, index) => {
134
+ restored = restored.replace(`\u0000CODE${index}\u0000`, block);
135
+ });
136
+ (element.text as { content: string }).content = restored;
137
+ }
138
+
139
+ return elements.length ? elements : [buildLarkDiv(content.trim())];
140
+ }
141
+
142
+ function buildCardElements(content: string): Record<string, unknown>[] {
143
+ const elements: Record<string, unknown>[] = [];
144
+ let lastIndex = 0;
145
+ for (const match of content.matchAll(TABLE_RE)) {
146
+ const start = match.index ?? 0;
147
+ const before = content.slice(lastIndex, start);
148
+ if (before.trim()) {
149
+ elements.push(...splitHeadings(before));
150
+ }
151
+ elements.push(parseMarkdownTable(match[1]) ?? buildLarkDiv(match[1].trim()));
152
+ lastIndex = start + match[1].length;
153
+ }
154
+ const rest = content.slice(lastIndex);
155
+ if (rest.trim()) {
156
+ elements.push(...splitHeadings(rest));
157
+ }
158
+ return elements.length ? elements : [buildLarkDiv(content.trim())];
159
+ }
160
+
161
+ function splitElementsByTableLimit(elements: Record<string, unknown>[], maxTables = 1): Record<string, unknown>[][] {
162
+ if (!elements.length) {
163
+ return [[]];
164
+ }
165
+ const groups: Record<string, unknown>[][] = [];
166
+ let current: Record<string, unknown>[] = [];
167
+ let tableCount = 0;
168
+
169
+ for (const element of elements) {
170
+ if (element.tag === 'table') {
171
+ if (tableCount >= maxTables) {
172
+ if (current.length) {
173
+ groups.push(current);
174
+ }
175
+ current = [];
176
+ tableCount = 0;
177
+ }
178
+ current.push(element);
179
+ tableCount += 1;
180
+ continue;
181
+ }
182
+ current.push(element);
183
+ }
184
+
185
+ if (current.length) {
186
+ groups.push(current);
187
+ }
188
+ return groups.length ? groups : [[]];
189
+ }
190
+
191
+ function normalizeRichMarkdown(content: string): string {
192
+ return content
193
+ .replace(/\r\n/g, '\n')
194
+ .replace(/\n{3,}/g, '\n\n')
195
+ .trim();
196
+ }
197
+
198
+ function splitMarkdownChunks(content: string, limit = TEXT_MAX_LEN): string[] {
199
+ const trimmed = normalizeRichMarkdown(content);
200
+ if (!trimmed) {
201
+ return [];
202
+ }
203
+
204
+ const chunks: string[] = [];
205
+ let remaining = trimmed;
206
+ while (remaining.length > limit) {
207
+ let cursor = remaining.lastIndexOf('\n\n', limit);
208
+ if (cursor < 0) cursor = remaining.lastIndexOf('\n', limit);
209
+ if (cursor < 0) cursor = remaining.lastIndexOf(' ', limit);
210
+ if (cursor < 0 || cursor < Math.floor(limit * 0.5)) cursor = limit;
211
+ chunks.push(remaining.slice(0, cursor).trim());
212
+ remaining = remaining.slice(cursor).trim();
213
+ }
214
+ if (remaining) {
215
+ chunks.push(remaining);
216
+ }
217
+ return chunks.filter(Boolean);
218
+ }
219
+
220
+ function toTextMessage(content: string): FeishuRenderedMessage {
221
+ return {
222
+ messageType: 'text',
223
+ content: JSON.stringify({ text: content }),
224
+ };
225
+ }
226
+
227
+ export function splitMarkdown(content: string, limit = TEXT_MAX_LEN): string[] {
228
+ return splitMarkdownChunks(content, limit);
229
+ }
230
+
231
+ function renderInteractiveCards(content: string): FeishuRenderedMessage[] {
232
+ const trimmed = content.trim();
233
+ if (WORKING_NOTICE_RE.test(trimmed)) {
234
+ return [{
235
+ messageType: 'interactive' as const,
236
+ content: buildWorkingNoticeCard(trimmed),
237
+ }];
238
+ }
239
+ return splitElementsByTableLimit(buildCardElements(trimmed))
240
+ .filter((group) => group.length > 0)
241
+ .map((group) => ({
242
+ messageType: 'interactive' as const,
243
+ content: buildCard(group),
244
+ }));
245
+ }
246
+
247
+ function shouldUseInteractiveCard(content: string): boolean {
248
+ return TABLE_DETECT_RE.test(content) || SYSTEM_NOTICE_RE.test(content) || WORKING_NOTICE_RE.test(content);
249
+ }
250
+
251
+ export function toInteractiveCard(content: string): string {
252
+ const trimmed = content.trim();
253
+ if (WORKING_NOTICE_RE.test(trimmed)) {
254
+ return buildWorkingNoticeCard(trimmed);
255
+ }
256
+ return buildCard(splitElementsByTableLimit(buildCardElements(trimmed)).flat());
257
+ }
258
+
259
+ export function renderFeishuMessages(content: string): FeishuRenderedMessage[] {
260
+ const trimmed = content.trim();
261
+ if (!trimmed) {
262
+ return [];
263
+ }
264
+ if (shouldUseInteractiveCard(trimmed)) {
265
+ return renderInteractiveCards(trimmed);
266
+ }
267
+ return splitMarkdownChunks(trimmed).map((chunk) => buildPostMessage(chunk));
268
+ }
269
+
270
+ export function renderFeishuPlainText(content: string): FeishuRenderedMessage[] {
271
+ const trimmed = content.trim();
272
+ if (!trimmed) {
273
+ return [];
274
+ }
275
+ return splitMarkdownChunks(trimmed).map((chunk) => toTextMessage(chunk));
276
+ }