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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,23 @@ line messages CHAT_ID --json
By default, `messages` fetches 1–100 recent messages. Human output sorts by
created time, oldest first; legacy `--json` retains the server's array order.
`--show-ids` includes the IDs needed for replies, reactions, downloads, and
unsend. To continue into older available history, use the opt-in page contract:
unsend. To inspect which IDs LINE returns before decryption, without printing
message content:

```sh
line --error-format=json messages "Chat name" --diagnostics --json --limit 50
```

This returns `schema_version: 1`, `chat_id`, `method: "getRecentMessagesV2"`,
`requested_count`, `returned_count`, `null_count`, `invalid_id_count`, and
`message_ids` in server order. IDs remain strings; malformed IDs are counted
instead of printed. It performs one logical recent-history read (authentication
recovery may retry), without decryption or key lookups. An absent ID means it
was not in this response, not that delivery failed or all history was searched.
`--diagnostics` requires `--json` and cannot combine with `--details`,
`--resolve-names`, `--page`, or `--cursor`. Normal history output is unchanged.

To continue into older available history, use the opt-in page contract:

```sh
line messages CHAT_ID --page --json --limit 50
Expand Down
16 changes: 16 additions & 0 deletions docs/ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@ and sequence are omitted when unavailable. The persisted request sequence is
within LINE's signed 32-bit range and remains a JSON number. This report is not
an operation ledger and does not supply a deduplication or replay API.

Request failures may additionally include `error.diagnostic`. Its `kind`
distinguishes `transport` (HTTP exchange failed), `response_read` (body could
not be read completely), `gateway` (HTTP/application error),
`response_envelope` (invalid JSON or missing/invalid success code),
`message_decode` (message data could not be decoded), and `missing_receipt`
(send returned no message or no ID). Gateway diagnostics include `http_status`
and, when available, numeric `gateway_code`. Only the known exception names
`TalkException` and `TokenAuthException` and their numeric `exception_code` are
exposed. Server reason strings, body contents, and transport error text are
never included. Other failures may omit diagnostics.

These details do not change outcome classification or authorize retries. A
gateway failure or a parsing failure can still follow a delivered message.
For missing-history investigations, `messages CHAT --diagnostics --json`
reports returned IDs before decryption without message text or encrypted data.

| Code | Meaning |
| --- | --- |
| `invalid_arguments` | Invalid or missing options/input; validation stopped the command. |
Expand Down
13 changes: 13 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ authentication rejection followed by failed recovery, lost/missing receipts,
preparation side effects, and plaintext-file upload failures without replay.
All use synthetic APIs/transports; no live LINE mutation is authorized by them.

Request diagnostic tests distinguish transport, response-read, gateway,
envelope, message-decoding, and missing-receipt failures; verify redaction and
wrapped cancellation; and exercise the protocol-to-session-to-CLI error path
with a persisted sequence and exactly one send. History diagnostic tests retain
large returned IDs even for undecryptable messages, count null/malformed IDs,
exclude message content, and reject incompatible flags before session access.

Group encryption tests cover negotiated V1/V2 and the absent-version default
for text, mentions, replies, and files; refreshed negotiation; unsupported
versions; and cipher failures without sending or downgrading to plaintext.
The E2EE manager suite includes a real synthetic-key V1 roundtrip and checks
that V2 encryption errors cannot fall back to mislabeled V1 chunks.

History tests cover lossless IDs/delivered times, chat-bound cursors, inclusive
and exclusive anchors, short/empty/malformed/nonadvancing pages, bounded lookup,
authentication recovery, wrong-chat replies, ownership, and older targets across
Expand Down
83 changes: 83 additions & 0 deletions docs/protocol/endpoints/history.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,86 @@ Supplementary evidence: the pinned
[linejs fetcher](https://github.com/evex-dev/linejs/blob/4fc512ab8c19a2490ca73bb71777beb775ca3ae3/packages/linejs/client/features/chat/fetcher.ts)
uses each page's last delivered time and lossless message ID as its next anchor.
Its binary Thrift transport is not evidence for the Chrome JSON argument shape.

## Send failure and history diagnostics (issue #20)

### Evidence

Inspected the official public LINE Chrome extension **3.7.2** on 2026-10-17,
matching the CLI version constants. The package was downloaded from Google's
extension update service for `ophjlpahpchlmihnnnihgmmeilfjmjjc` and verified as
SHA-256 `3722ab02edb0aba49c229399ee8df5b4b5a38c60b76efadadfbae395015eb859`.
LY Corporation retains copyright in the extension. Its source is not committed.
No Chrome login, session replacement, or browser traffic capture was performed.

This investigation distinguishes transport/response failures from service
exceptions, checks whether recent-history records disappear during decoding,
and corrects group sends to honor the negotiated encryption version. Mutation
retries remain disabled.

Locators refer to `static/js/main.js` in that exact package:

| Locator | Observed behavior |
| --- | --- |
| `mU.E2EE_RETRY_ENCRYPT` | Talk error enum value **82**. This is distinct from sender-key update (83), receiver-key update (84), and group-key recreation (99). |
| Send-error switch using `mU.E2EE_RETRY_ENCRYPT` | When E2EE is enabled and content is encryptable, Chrome resets chat negotiation and retries its send flow. This CLI deliberately does not copy mutation retries. |
| `LA.negotiate`, `renewLatestGroupKey`, `unwrapGroupKey` | Group negotiation reads `getLastE2EEGroupSharedKey(1, chat)` and retains the returned `allowedTypes` and `specVersion`. |
| `encryptGroupMessage`, `encryptWithE2eeChannel`, `EL` | Chrome uses negotiated encryption version, constructs chunks, removes outer text/location, and sets string `contentMetadata.e2eeVersion`. |
| `MP`, `Rj` | `sendMessage` takes the request sequence and the prepared message. |

### Authorized live observations

Two single-attempt sends were performed in the user-approved two-member test
group: ordinary text and an explicit mention. Both returned HTTP **400**,
gateway code **10051**, `TalkException` code **82**. Their outcomes remain
`unknown`; neither was retried. The planned reply to the baseline was skipped
because the baseline had no confirmed receipt.

Recent-history diagnostics returned three records, with no null records or
invalid IDs. Detailed history contained the same three IDs. Neither test text
appeared, and none of the returned records carried a reply relationship. This
establishes no decode-stage omission in that sample, not exhaustive history or
non-delivery of the attempted sends.

A separate read-only check found saved E2EE enabled, saved keys present, and a
current group shared-key response available (no explicit disabled capability
or missing-key response). Those facts do not establish that the encryption
envelope, key freshness, or negotiated version used at send time was correct.
The original issue's visibly delivered reply remains unexplained.

### Root cause and correction

A read-only negotiation plus locally intercepted send showed `specVersion: 1`,
but the CLI generated V2 chunks and set `e2eeVersion: "2"`. The group key's
negotiated version was discarded by the messaging layer. This explains the
reproduced code-82 failures for both ordinary text and mentions.

Group sends now select V1 or V2 explicitly from the latest key response and
label the envelope with the same version. An absent version defaults to V2,
matching Chrome's `Nd` default; unsupported versions fail before sending.
The former V2-error fallback to V1 was removed because it returned V1 chunks
under a V2 label. Cipher failures remain failures, with no plaintext downgrade
or automatic mutation retry. The same selection applies to attachment payloads.

The corrected local interception produced the V1 five-chunk framing (8-byte
salt, 16-byte MAC, two 4-byte key IDs), labeled version 1 with no outer plaintext.
Chrome locators `mL` (V1) and `vL` (V2) corroborate these framing differences.

### Live verification after the fix

One baseline, one mention, and one reply were sent using the previously approved
content, only in the authorized two-member test group. All three returned
confirmed encrypted receipts without group-key registration. Subsequent recent
history returned seven records with no null or invalid IDs. Each new receipt
appeared in both pre-decryption diagnostics and detailed history, decrypted
successfully, and matched the approved text. The decoded mention targeted the
expected other member; the reply relationship matched the confirmed baseline.
Each verification send was attempted once, without automatic retries.

This confirms the corrected group text/mention/reply flow. It does not establish
what happened to the original issue's historical reply, or live attachment/V2
interoperability; those paths have synthetic regression coverage.

No private IDs, message bodies, keys, encrypted chunks, raw responses, or
captures are retained here. See [CLI diagnostics](../../CLI.md) and
[structured error details](../../ERRORS.md) for the new safe output contract.
93 changes: 93 additions & 0 deletions internal/cli/diagnostics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package cli

import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"

"github.com/kongesque/line-cli/internal/fault"
"github.com/kongesque/line-cli/internal/messaging"
"github.com/kongesque/line-cli/internal/session"
"github.com/kongesque/line-cli/pkg/line"
)

type diagnosticTransport func(*http.Request) (*http.Response, error)

func (f diagnosticTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

type diagnosticSendAPI struct {
messageAPI
client *line.Client
}

func (f *diagnosticSendAPI) SendMessageContext(ctx context.Context, seq int64, msg *line.Message) (*line.Message, error) {
return f.client.SendMessageContext(ctx, seq, msg)
}

func TestSendDiagnosticSurvivesSessionAndMachineWrapping(t *testing.T) {
for _, tc := range []struct{ body, kind string }{
{`{"code":10051,"data":{"name":"TalkException","code":82,"reason":"private-reason"}}`, "gateway"},
{`{"code":0,"data":{"id":123,"text":"private-text"}}`, "message_decode"},
{`{"code":0,"data":null}`, "missing_receipt"},
} {
a, out, _ := testApp(nil)
calls := 0
client := line.NewClient("synthetic")
client.HTTPClient = &http.Client{Transport: diagnosticTransport(func(*http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(tc.body))}, nil
})}
f := &diagnosticSendAPI{client: client}
store := &testStore{state: &session.State{MID: "u-self", AccessToken: "synthetic", NoE2EE: true}}
a.Manager.Store = store
a.Manager.NewClient = func(string) session.API { return f }
err := a.Run([]string{"--error-format=json", "send", "id:c-group", "--text", "synthetic", "--json"})
d := machineDetail(t, err)
if d.Code != "remote_failed" || d.Stage != "send" || d.Diagnostic == nil || d.Diagnostic.Kind != tc.kind || d.Mutation == nil || d.Mutation.Outcome != fault.Unknown || !d.Mutation.Attempted || calls != 1 || out.Len() != 0 {
t.Fatalf("incorrect send result: %+v; calls %d", d, calls)
}
if d.Mutation.RequestSequence == nil || *d.Mutation.RequestSequence != store.state.LastReqSeq {
t.Fatal("lost persisted sequence")
}
if tc.kind == "gateway" && (d.Diagnostic.ExceptionCode == nil || *d.Diagnostic.ExceptionCode != 82) {
t.Fatal("lost E2EE_RETRY_ENCRYPT diagnostic")
}
encoded, _ := json.Marshal(d)
if strings.Contains(string(encoded), "private-") {
t.Fatal("machine channel leaked data")
}
}
}

func TestHistoryDiagnosticsIDsBeforeDecryption(t *testing.T) {
a, out, _ := testApp(nil)
f := &messageAPI{history: []*line.Message{
{ID: "9007199254740993", Text: "private-text", Chunks: []string{"private-chunk"}, ContentMetadata: map[string]string{"e2eeVersion": "2"}},
nil, {ID: "private-invalid-id"}, {ID: "9007199254740994", RelatedMessageID: "123"},
}}
a.Manager.Store = &testStore{state: &session.State{MID: "u-self", AccessToken: "synthetic", NoE2EE: true}}
a.Manager.NewClient = func(string) session.API { return f }
if err := a.Run([]string{"messages", "id:c-group", "--diagnostics", "--json", "--limit", "50"}); err != nil {
t.Fatal(err)
}
var d messaging.HistoryDiagnostics
if json.Unmarshal(out.Bytes(), &d) != nil || d.SchemaVersion != 1 || d.Method != "getRecentMessagesV2" || d.RequestedCount != 50 || d.ReturnedCount != 4 || d.NullCount != 1 || d.InvalidIDCount != 1 || len(d.MessageIDs) != 2 || d.MessageIDs[0] != "9007199254740993" || d.MessageIDs[1] != "9007199254740994" {
t.Fatalf("diagnostics: %s", out.String())
}
if strings.Contains(out.String(), "private-") || strings.Contains(out.String(), "chunks") || strings.Contains(out.String(), "text") {
t.Fatal("history diagnostics leaked message data")
}
}

func TestHistoryDiagnosticFlagsValidateBeforeSession(t *testing.T) {
for _, extra := range [][]string{{}, {"--json", "--details"}, {"--json", "--page"}, {"--json", "--cursor", "bad"}, {"--json", "--resolve-names"}} {
a, _, _ := testApp(nil)
a.Lock = func() (func(), error) { t.Fatal("invalid flags accessed session"); return nil, nil }
if err := a.Run(append([]string{"messages", "id:c-group", "--diagnostics"}, extra...)); err == nil {
t.Fatalf("accepted invalid flags: %v", extra)
}
}
}
3 changes: 3 additions & 0 deletions internal/cli/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ func DescribeError(err error, stage string, op *fault.Mutation) *fault.Error {
}
detail := fault.New(code, stage, message, err)
detail.Mutation = op
if d := line.RequestDiagnostics(err); d != nil {
detail.Diagnostic = &fault.Diagnostic{Kind: d.Kind, HTTPStatus: d.HTTPStatus, GatewayCode: d.GatewayCode, Exception: d.Exception, ExceptionCode: d.ExceptionCode}
}
if op != nil {
switch op.Outcome {
case fault.Unknown:
Expand Down
12 changes: 12 additions & 0 deletions internal/cli/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func (a *App) messageCommand(command string, args []string) error {
limit := 20
var pageMode bool
var details, resolveNames bool
var diagnostics bool
var cursor string
var text string
var stdin bool
Expand All @@ -43,6 +44,7 @@ func (a *App) messageCommand(command string, args []string) error {
var attachment *messaging.Attachment
showIDs := fs.Bool("show-ids", false, "show message IDs in human output")
if command == "messages" {
fs.BoolVar(&diagnostics, "diagnostics", false, "report recent-history IDs and counts before decryption, without content (requires --json; excludes --details/--page)")
fs.BoolVar(&details, "details", false, "include safe decoded content in a versioned envelope (requires --json)")
fs.BoolVar(&resolveNames, "resolve-names", false, "resolve sender names in batches (requires --details --json)")
fs.IntVar(&limit, "limit", 20, "maximum messages to fetch (1–100)")
Expand Down Expand Up @@ -81,6 +83,9 @@ func (a *App) messageCommand(command string, args []string) error {
if details && !*jsonOutput {
return errors.New("--details requires --json")
}
if diagnostics && (!*jsonOutput || details || pageMode || resolveNames) {
return errors.New("--diagnostics requires --json and cannot be combined with --details, --page, or --resolve-names")
}
if resolveNames && !details {
return errors.New("--resolve-names requires --details --json")
}
Expand Down Expand Up @@ -291,6 +296,13 @@ func (a *App) messageCommand(command string, args []string) error {
return a.confirmedOutput(op, err)
}
var messages []messaging.Message
if diagnostics {
result, err := client.HistoryDiagnosticsContext(a.loginContext(), chat, limit)
if err != nil {
return err
}
return a.json(result)
}
var page *messaging.HistoryPage
if pageMode {
page, err = client.HistoryPageContext(a.loginContext(), chat, limit, cursor)
Expand Down
20 changes: 15 additions & 5 deletions internal/fault/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,22 @@ type Mutation struct {
Steps []Step `json:"steps,omitempty"`
}

// Diagnostic is the allowlisted request failure detail in the machine channel.
type Diagnostic struct {
Kind string `json:"kind"`
HTTPStatus int `json:"http_status,omitempty"`
GatewayCode *int `json:"gateway_code,omitempty"`
Exception string `json:"exception,omitempty"`
ExceptionCode *int `json:"exception_code,omitempty"`
}

type Error struct {
Code string `json:"code"`
Stage string `json:"stage"`
Message string `json:"message"`
Mutation *Mutation `json:"mutation,omitempty"`
cause error
Code string `json:"code"`
Stage string `json:"stage"`
Message string `json:"message"`
Mutation *Mutation `json:"mutation,omitempty"`
Diagnostic *Diagnostic `json:"diagnostic,omitempty"`
cause error
}

func New(code, stage, message string, cause error) *Error {
Expand Down
Loading
Loading